From 494d60a671656fddc016f3375e39337ed9f0bc8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 28 Sep 2026 11:52:25 +0200 Subject: [PATCH 001/325] :bug: Fix script injection in GitHub Actions workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values coming from `${{ }}` expressions were interpolated directly into `run:` scripts, so GitHub substituted them into the shell source before bash parsed it. A commit title containing a double quote broke the "Write step summary" step of the bundle build with a syntax error, and the same pattern allowed arbitrary command execution on the self-hosted runners. Pass every expression used inside `run:` through step/job `env:` and reference it as a quoted shell variable instead. Use the runner's default variables (GITHUB_RUN_ID, GITHUB_REPOSITORY, ...) where the value comes from the `github` context. Also validate `plugin_name` in plugins-deploy-package.yml against `^[a-z0-9][a-z0-9-]*$`, since it is free-form and reaches paths, worker names, GITHUB_ENV and action inputs. Affected workflows: build-bundle, build-docker, build-docker-admin-console, plugins-deploy-package, plugins-deploy-api-doc, plugins-deploy-styles-doc, release, tests-e2e. Signed-off-by: David Barragán Merino --- .github/workflows/build-bundle.yml | 31 +++++--- .../workflows/build-docker-admin-console.yml | 18 +++-- .github/workflows/build-docker.yml | 77 +++++++++++++------ .github/workflows/plugins-deploy-api-doc.yml | 9 ++- .github/workflows/plugins-deploy-package.yml | 34 +++++--- .../workflows/plugins-deploy-styles-doc.yml | 9 ++- .github/workflows/release.yml | 4 +- .github/workflows/tests-e2e.yml | 6 +- 8 files changed, 128 insertions(+), 60 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e7f1f3a815..8db99ea68c 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -62,8 +62,10 @@ jobs: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT @@ -79,8 +81,12 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + FORCE: ${{ inputs.force }} + SHORT_SHA: ${{ steps.vars.outputs.short_sha }} + BUNDLE_VERSION: ${{ steps.vars.outputs.bundle_version }} run: | - if [ "${{ inputs.force }}" = "true" ]; then + if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT { echo "### 🔁 Bundle build forced" @@ -91,8 +97,8 @@ jobs: fi BUNDLE_EXISTS=$(aws s3api head-object \ - --bucket ${{ secrets.S3_BUCKET }} \ - --key "penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" \ + --bucket "$S3_BUCKET" \ + --key "penpot-sha-${SHORT_SHA}.zip" \ > /dev/null 2>&1 && echo "true" || echo "false") if [ "$BUNDLE_EXISTS" = "true" ]; then @@ -100,7 +106,7 @@ jobs: { echo "### ⏭️ Bundle build skipped" echo "" - echo "The bundle in S3 was already built from \`sha-${{ steps.vars.outputs.short_sha }}\` (\`${{ steps.vars.outputs.bundle_version }}\`)." + echo "The bundle in S3 was already built from \`sha-${SHORT_SHA}\` (\`${BUNDLE_VERSION}\`)." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -142,18 +148,25 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + SHORT_SHA: ${{ needs.check.outputs.short_sha }} + BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }} run: | aws s3 cp zips/penpot.zip \ - s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ needs.check.outputs.short_sha }}.zip \ - --metadata bundle-version=${{ needs.check.outputs.bundle_version }} + "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" \ + --metadata "bundle-version=${BUNDLE_VERSION}" - name: Write step summary + env: + SHORT_SHA: ${{ needs.check.outputs.short_sha }} + BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }} + COMMIT_TITLE: ${{ needs.check.outputs.commit_title }} run: | { echo "### ✅ Bundle built" echo "" - echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)" - echo "- Commit: [\`${{ needs.check.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.short_sha }}) — ${{ needs.check.outputs.commit_title }}" + echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml index dbc28387b6..4c11db6fba 100644 --- a/.github/workflows/build-docker-admin-console.yml +++ b/.github/workflows/build-docker-admin-console.yml @@ -48,16 +48,17 @@ jobs: WORKFLOW: build-docker-admin-console.yml GH_REF: ${{ inputs.gh_ref }} DISPATCH_REF: ${{ inputs.dispatch_ref }} + FORCE: ${{ inputs.force }} steps: - name: Trigger nitrate docker build id: dispatch run: | - DISTINCT_ID="${{ github.run_id }}-${{ github.run_attempt }}" - CALLER_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + DISTINCT_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + CALLER_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \ -f gh_ref="$GH_REF" \ - -f force="${{ inputs.force }}" \ + -f force="$FORCE" \ -f caller_run_id="$DISTINCT_ID" \ -f caller_run_url="$CALLER_URL" @@ -83,20 +84,25 @@ jobs: echo "::notice title=Nitrate docker build::$RUN_URL" - name: Wait for nitrate docker build + env: + RUN_ID: ${{ steps.dispatch.outputs.run_id }} run: | - gh run watch "${{ steps.dispatch.outputs.run_id }}" \ + gh run watch "$RUN_ID" \ --repo "$REPO" \ --interval 30 \ --exit-status - name: Report result if: always() && steps.dispatch.outputs.run_id != '' + env: + RUN_ID: ${{ steps.dispatch.outputs.run_id }} + RUN_URL: ${{ steps.dispatch.outputs.run_url }} run: | - CONCLUSION=$(gh run view "${{ steps.dispatch.outputs.run_id }}" \ + CONCLUSION=$(gh run view "$RUN_ID" \ --repo "$REPO" --json conclusion --jq '.conclusion') { echo "### 🐳 Nitrate docker build" echo "" echo "- Result: \`${CONCLUSION:-in_progress}\`" - echo "- Run: ${{ steps.dispatch.outputs.run_url }}" + echo "- Run: ${RUN_URL}" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index d24b09436e..9c86d6ea5d 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -73,8 +73,9 @@ jobs: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT @@ -92,13 +93,16 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + FORCE: ${{ inputs.force }} + SHORT_SHA: ${{ steps.vars.outputs.short_sha }} run: | - if [ "${{ inputs.force }}" = "true" ]; then + if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" - aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" "$ZIP.$$.tmp" + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" { echo "### 🔁 Image set build forced" @@ -109,14 +113,14 @@ jobs: fi if aws s3api head-object \ - --bucket ${{ secrets.S3_BUCKET }} \ - --key "markers/images-sha-${{ steps.vars.outputs.short_sha }}" \ + --bucket "$S3_BUCKET" \ + --key "markers/images-sha-${SHORT_SHA}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.short_sha }}\`." + echo "The whole set was already built and promoted for \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -126,9 +130,9 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" if [ ! -f "$ZIP" ]; then - aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ steps.vars.outputs.short_sha }}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi fi @@ -197,18 +201,21 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + IMAGE: ${{ matrix.image }} run: | - ZIP="$BUNDLE_CACHE/penpot-sha-${{ needs.prepare.outputs.short_sha }}.zip" + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" - aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-sha-${{ needs.prepare.outputs.short_sha }}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi # Extract only the bundle this job needs. pushd docker/images - unzip -q "$ZIP" "penpot/${{ matrix.image }}/*" - mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.image }}" + unzip -q "$ZIP" "penpot/${IMAGE}/*" + mv "penpot/${IMAGE}" "bundle-${IMAGE}" popd - name: Set up QEMU (stable) @@ -266,12 +273,16 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Point branch tags to the new build key + env: + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + GH_REF: ${{ needs.prepare.outputs.gh_ref }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ - -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.short_sha }}" + -t "${DOCKER_REGISTRY}/${image}:${GH_REF}" \ + "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" done # The marker is written LAST: its presence certifies that all five @@ -281,20 +292,27 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | - echo "${{ github.run_id }}" | aws s3 cp - \ - "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.short_sha }}" + echo "$GITHUB_RUN_ID" | aws s3 cp - \ + "s3://${S3_BUCKET}/markers/images-sha-${SHORT_SHA}" - name: Write step summary + env: + GH_REF: ${{ needs.prepare.outputs.gh_ref }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} + COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set promoted" echo "" - echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)" - echo "- Commit: [\`${{ needs.prepare.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.short_sha }}) — ${{ needs.prepare.outputs.commit_title }}" + echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.short_sha }}\`." + echo "All \`:${GH_REF}\` tags now point to \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 3b. Skip path: make sure THIS ref's tags point to the existing @@ -324,24 +342,33 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Point branch tags to the existing build key + env: + DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} + GH_REF: ${{ needs.prepare.outputs.gh_ref }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ - -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.short_sha }}" + -t "${DOCKER_REGISTRY}/${image}:${GH_REF}" \ + "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" done - name: Write step summary + env: + GH_REF: ${{ needs.prepare.outputs.gh_ref }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} + COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set already built (branch tags ensured)" echo "" - echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)" - echo "- Commit: [\`${{ needs.prepare.outputs.short_sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.short_sha }}) — ${{ needs.prepare.outputs.commit_title }}" + echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Checked at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.short_sha }}\`." + echo "All \`:${GH_REF}\` tags now point to \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 4657740a58..12e6352688 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -33,8 +33,10 @@ jobs: steps: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout uses: actions/checkout@v6 @@ -78,8 +80,9 @@ jobs: run: pnpm run build:doc - name: Select Worker name + env: + REF: ${{ steps.vars.outputs.gh_ref }} run: | - REF="${{ steps.vars.outputs.gh_ref }}" case "$REF" in main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV @@ -97,7 +100,7 @@ jobs: working-directory: plugins shell: bash run: | - sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml + sed -i "s/WORKER_URI/${WORKER_URI}/g" wrangler-penpot-plugins-api-doc.toml - name: Add noindex header and robots.txt files for non-production environments if: ${{ steps.vars.outputs.gh_ref != 'main' }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index cb6cbc704c..c8422566d8 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -35,7 +35,20 @@ permissions: jobs: deploy: runs-on: penpot-standar-runner + env: + GH_REF: ${{ inputs.gh_ref }} + PLUGIN_NAME: ${{ inputs.plugin_name }} steps: + # plugin_name is a free-form string that ends up in paths, worker names + # and GITHUB_ENV; reject anything that is not a plain slug before it is + # used anywhere. + - name: Validate inputs + run: | + if ! [[ "$PLUGIN_NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "::error::Invalid plugin_name: must match ^[a-z0-9][a-z0-9-]*$" + exit 1 + fi + - name: Checkout uses: actions/checkout@v6 with: @@ -75,29 +88,28 @@ jobs: - name: "Build package for ${{ inputs.plugin_name }}-plugin" working-directory: plugins shell: bash - run: pnpm --filter ${{ inputs.plugin_name }}-plugin build + run: pnpm --filter "${PLUGIN_NAME}-plugin" build - name: Select Worker name run: | - REF="${{ inputs.gh_ref }}" - case "$REF" in + case "$GH_REF" in main) - echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV - echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;; + echo "WORKER_NAME=${PLUGIN_NAME}-plugin-pro" >> $GITHUB_ENV + echo "WORKER_URI=${PLUGIN_NAME}.plugins.penpot.app" >> $GITHUB_ENV ;; staging) - echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV - echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;; + echo "WORKER_NAME=${PLUGIN_NAME}-plugin-pre" >> $GITHUB_ENV + echo "WORKER_URI=${PLUGIN_NAME}.plugins.penpot.dev" >> $GITHUB_ENV ;; develop) - echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV - echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;; - *) echo "Unsupported branch ${REF}" && exit 1 ;; + echo "WORKER_NAME=${PLUGIN_NAME}-plugin-hourly" >> $GITHUB_ENV + echo "WORKER_URI=${PLUGIN_NAME}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;; + *) echo "Unsupported branch ${GH_REF}" && exit 1 ;; esac - name: Set the custom url working-directory: plugins shell: bash run: | - sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml + sed -i "s/WORKER_URI/${WORKER_URI}/g" "apps/${PLUGIN_NAME}-plugin/wrangler.toml" - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 53a379b854..39c14580c2 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -31,8 +31,10 @@ jobs: steps: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout uses: actions/checkout@v6 @@ -76,8 +78,9 @@ jobs: run: pnpm run build:styles-example - name: Select Worker name + env: + REF: ${{ steps.vars.outputs.gh_ref }} run: | - REF="${{ steps.vars.outputs.gh_ref }}" case "$REF" in main) echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV @@ -95,7 +98,7 @@ jobs: working-directory: plugins shell: bash run: | - sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml + sed -i "s/WORKER_URI/${WORKER_URI}/g" wrangler-penpot-plugins-styles-doc.toml - name: Add noindex header and robots.txt files for non-production environments if: ${{ steps.vars.outputs.gh_ref != 'main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d54b33ec03..7468f8d4d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,10 @@ jobs: steps: - name: Extract some useful variables id: vars + env: + GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout code uses: actions/checkout@v6 diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index a70008b4e4..ccbde28970 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -190,12 +190,14 @@ jobs: working-directory: ./frontend env: WORKERS: ${{ inputs.workers }} + SHARD: ${{ matrix.shard }} + SHARD_TOTAL: ${{ strategy.job-total }} run: | WORKERS=${WORKERS:-2} - echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers" + echo "Running shard ${SHARD}/${SHARD_TOTAL} with $WORKERS workers" pnpm exec playwright test --project default \ --workers="$WORKERS" \ - --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ + --shard="${SHARD}/${SHARD_TOTAL}" \ --reporter=blob - name: Upload blob report From efbb554af17c62a3ada8b07f7d942cd9b5e08243 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Mon, 28 Sep 2026 18:19:55 +0200 Subject: [PATCH 002/325] :recycle: Move use-shape into app.common.render_wasm (#11917) - Introduces a new ns since there wasn't a suitable one - Simplifies serialize-shapes-batch! and allows usage outside the frontend --- .../app/common/render_wasm/api/select.cljs | 28 +++++++++++++ .../common/render_wasm/serialize_shape.cljs | 11 +++--- .../app/main/data/workspace/modifiers.cljs | 3 +- .../app/main/data/workspace/transforms.cljs | 3 +- .../app/main/data/workspace/wasm_text.cljs | 3 +- frontend/src/app/main/render_viewer_wasm.cljs | 5 ++- .../sidebar/options/menus/layer.cljs | 5 ++- .../app/main/ui/workspace/viewport_wasm.cljs | 7 ++-- frontend/src/app/render_wasm/api.cljs | 39 +++++++------------ frontend/src/app/render_wasm/shape.cljs | 3 +- .../test/frontend_tests/helpers/wasm.cljs | 7 ++-- .../render_wasm/serialization_test.cljs | 29 +++++++------- 12 files changed, 85 insertions(+), 58 deletions(-) create mode 100644 common/src/app/common/render_wasm/api/select.cljs diff --git a/common/src/app/common/render_wasm/api/select.cljs b/common/src/app/common/render_wasm/api/select.cljs new file mode 100644 index 0000000000..f461c06302 --- /dev/null +++ b/common/src/app/common/render_wasm/api/select.cljs @@ -0,0 +1,28 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.render-wasm.api.select + "WASM cursor selection. + Note that setters in `app.common.render-wasm.api.props` assume the caller + selected first." + (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm] + [app.common.uuid :as uuid])) + +(defn use-shape + "Makes the shape with `id` current in WASM so subsequent setters apply to it. + This is a no-op when there is no WASM context." + [id] + ;; Use `wasm/live?` (not `initialized?`) so context-restore reload can + ;; select shapes while `reloading?` still blocks external app callers. + (when (wasm/live?) + (let [buffer (uuid/get-u32 id)] + (h/call wasm/internal-module "_use_shape" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))))) diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index 07ce3697ab..1df4a84505 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -25,6 +25,7 @@ dispatching per changed key through the same underlying `props` setters." (:require [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.select :as wselect] [app.common.render-wasm.api.upload :as upload] [app.common.render-wasm.svg-derived :as svg-derived])) @@ -62,15 +63,15 @@ "Structural batch upload plus per-shape svg-attrs/path tail. Derives via svg-derived, uploads one `_set_shapes_batch` with `opts`, - then selects each shape needing svg-attrs/path content and applies it. - Host text/grid/image sequencing stays in callers. Returns the prepared - vector for the downstream host-attrs loop." - [shapes opts select-fn] + then selects each shape needing svg-attrs/path content via `use-shape!` + and applies the tail. Host text/grid/image sequencing stays in callers. + Returns the prepared vector for the downstream host-attrs loop." + [shapes opts] (let [prepared (mapv svg-derived/apply-svg-derived shapes)] (when (seq prepared) (upload/flush-shapes-batch! prepared opts) (doseq [shape prepared] (when (needs-shape-tail? shape) - (select-fn (:id shape)) + (wselect/use-shape (:id shape)) (write-shape-tail! shape)))) prepared)) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 47d97b4249..2cee1752a2 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -17,6 +17,7 @@ [app.common.geom.shapes :as gsh] [app.common.logging :as log] [app.common.math :as mth] + [app.common.render-wasm.api.select :as wselect] [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] @@ -813,7 +814,7 @@ ;; propagating geometry, so propagate_modifiers sees the updated state. (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] (when (= property :grow-type) - (wasm.api/use-shape id) + (wselect/use-shape id) (wasm.api/set-shape-grow-type value))) (let [objects (dsh/lookup-page-objects state) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 15990f2de3..5e5c033329 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -20,6 +20,7 @@ [app.common.geom.shapes.grid-layout :as gslg] [app.common.logic.shapes :as cls] [app.common.math :as mth] + [app.common.render-wasm.api.select :as wselect] [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] @@ -791,7 +792,7 @@ [target-frame objects position] (if (features/active-feature? @st/state "render-wasm/v1") (do - (wasm.api/use-shape target-frame) + (wselect/use-shape target-frame) (let [cell (wasm.api/get-grid-coords position)] (when (not= cell [-1 -1]) cell))) (gslg/get-drop-cell target-frame objects position))) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index ddd4f81400..3162656735 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -14,6 +14,7 @@ [app.common.files.helpers :as cfh] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] + [app.common.render-wasm.api.select :as wselect] [app.common.types.modifiers :as ctm] [app.common.types.text :as ctt] [app.main.data.helpers :as dsh] @@ -40,7 +41,7 @@ ;; text shape is being edited): there is no design state to query, and ;; returning nil makes callers skip the WASM resize/modifier path. (when (and id (wasm.api/initialized?)) - (wasm.api/use-shape id) + (wselect/use-shape id) ;; While the WASM text editor is actively editing it already holds the live ;; content and layout. Re-pushing the content here calls `_clear_shape_text` ;; + `_update_shape_text_layout`, which resets the editor and drops every diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 9a6bca0d99..18b34fe5a1 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -9,6 +9,7 @@ (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.render-wasm.api.select :as wselect] [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] @@ -83,14 +84,14 @@ (when (seq include-ids) (wasm.api/set-render-include-filter! include-ids)) (doseq [id clear-fills-ids] - (wasm.api/use-shape id) + (wselect/use-shape id) (wasm.api/clear-shape-fills!))) (defn- viewer-restore-layer-mask! [page-objects clear-fills-ids] (wasm.api/clear-render-include-filter!) (doseq [id clear-fills-ids] - (wasm.api/use-shape id) + (wselect/use-shape id) (wasm.api/set-shape-fills id (get-in page-objects [id :fills] []) false))) (defn- viewer-do-render! diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index c74fbdca56..eb8f59542b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.render-wasm.api.select :as wselect] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.application :as dwta] @@ -116,7 +117,7 @@ (when wasm-renderer-enabled? (doseq [id ids] - (wasm.api/use-shape id) + (wselect/use-shape id) (wasm.api/set-shape-blend-mode value) (wasm.api/request-render "preview-blend-mode"))) @@ -130,7 +131,7 @@ (swap! state* assoc :preview-complete? true) (when wasm-renderer-enabled? (doseq [id ids] - (wasm.api/use-shape id) + (wselect/use-shape id) (wasm.api/set-shape-blend-mode current-blend-mode) (wasm.api/request-render "preview-blend-mode"))) (st/emit! (dw/unset-preview-blend-mode ids)))) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 39bbef583b..3705839275 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.shapes :as gsh] + [app.common.render-wasm.api.select :as wselect] [app.common.types.color :as clr] [app.common.types.component :as ctk] [app.common.types.shape :as cts] @@ -502,7 +503,7 @@ (let [content (-> active-editor-state (ted/get-editor-current-content) (ted/export-content))] - (wasm.api/use-shape edition) + (wselect/use-shape edition) (wasm.api/set-shape-text-content edition content) (let [dimension (wasm.api/get-text-dimensions)] (st/emit! (dwt/resize-text-editor edition dimension)) @@ -651,12 +652,12 @@ (mf/with-effect [path-editing? edition @initialized?] (when (and path-editing? edition @initialized?) - (wasm.api/use-shape edition) + (wselect/use-shape edition) (wasm.api/set-shape-hidden true) (wasm.api/request-render "start-path-edition") (fn [] (when (wasm.api/initialized?) - (wasm.api/use-shape edition) + (wselect/use-shape edition) (wasm.api/set-shape-hidden false) (wasm.api/request-render "stop-path-edition"))))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0c5a4c3d0b..dd5d75f4da 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -17,6 +17,7 @@ [app.common.logging :as log] [app.common.math :as mth] [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.select :as wselect] [app.common.render-wasm.helpers :as h] [app.common.render-wasm.mem :as mem] [app.common.render-wasm.mem.heap32 :as mem.h32] @@ -727,18 +728,6 @@ (declare get-text-dimensions) -(defn use-shape - [id] - ;; Use `wasm/live?` (not `initialized?`) so context-restore reload can - ;; select shapes while `reloading?` still blocks external app callers. - (when (wasm/live?) - (let [buffer (uuid/get-u32 id)] - (h/call wasm/internal-module "_use_shape" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3))))) - (defn has-shape [id] (when (wasm/live?) @@ -793,7 +782,7 @@ Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving. `:with-fills?` also returns the selection's `:fills`." [styles & [opts]] - (let [result (text-editor/apply-styles-to-selection styles use-shape set-shape-text-content opts)] + (let [result (text-editor/apply-styles-to-selection styles wselect/use-shape set-shape-text-content opts)] (request-render "apply-styles-to-selection") result)) @@ -801,7 +790,7 @@ "Apply paragraph attrs to the paragraphs the editor selection touches. Returns {:shape-id :content} for saving." [attrs] - (let [result (text-editor/apply-paragraph-attrs-to-selection attrs use-shape set-shape-text-content)] + (let [result (text-editor/apply-paragraph-attrs-to-selection attrs wselect/use-shape set-shape-text-content)] (request-render "apply-paragraph-attrs-to-selection") result)) @@ -811,7 +800,7 @@ [shape-id range] (when-let [styles (text-editor/get-pending-caret-styles shape-id)] (let [result (text-editor/apply-styles-to-range - shape-id range styles use-shape set-shape-text-content)] + shape-id range styles wselect/use-shape set-shape-text-content)] (text-editor/clear-pending-caret-styles!) (request-render "apply-pending-caret-styles") result))) @@ -1130,7 +1119,7 @@ (h/call wasm/internal-module "_set_shape_hidden" hidden)) (defn clear-shape-fills! - "Clear the fills of the currently-selected shape (call `use-shape` first). + "Clear the fills of the currently-selected shape (call `use-shape!` first). Equivalent to `set-shape-fills` with an empty collection." [] (when (initialized?) @@ -1407,7 +1396,7 @@ (defn get-text-dimensions ([id] - (use-shape id) + (wselect/use-shape id) (get-text-dimensions)) ([] (if-not (initialized?) @@ -1588,7 +1577,7 @@ `skip-fills-strokes?` is true, fill/stroke records were already in the batch; only image fetches remain. - Always `use-shape` first: after a multi-shape batch the WASM current shape is + Always `use-shape!` first: after a multi-shape batch the WASM current shape is the last record in the chunk, not this shape." [shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}] (let [id (dm/get-prop shape :id) @@ -1608,7 +1597,7 @@ true))] (when needs-current? - (use-shape id)) + (wselect/use-shape id)) (when (and (some? content) (= type :svg-raw)) (set-shape-svg-raw-content (get-static-markup shape))) @@ -1814,8 +1803,7 @@ prepared (serialize-shape/serialize-shapes-batch! chunk {:include-layout? true - :include-fills-strokes? true} - use-shape)] + :include-fills-strokes? true})] (loop [xs prepared t-acc (transient thumbnails-acc) @@ -1964,8 +1952,7 @@ (let [prepared (serialize-shape/serialize-shapes-batch! shapes {:include-layout? true - :include-fills-strokes? true} - use-shape) + :include-fills-strokes? true}) total-shapes (count prepared) {:keys [thumbnails full text-font-state]} (loop [index 0 @@ -2772,7 +2759,7 @@ (defn shape-to-path [id] (when (initialized?) - (use-shape id) + (wselect/use-shape id) (try (let [offset (-> (h/call wasm/internal-module "_current_to_path") (mem/->offset-32)) @@ -2796,7 +2783,7 @@ the fill rule itself)." [id stroke-index] (when (initialized?) - (use-shape id) + (wselect/use-shape id) (try (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) (mem/->offset-32)) @@ -2886,7 +2873,7 @@ (defn calculate-position-data [shape] (when (initialized?) - (use-shape (:id shape)) + (wselect/use-shape (:id shape)) (let [heapf32 (mem/get-heap-f32) heapu32 (mem/get-heap-u32) offset (-> (h/call wasm/internal-module "_calculate_position_data") diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 995ea816cd..3d895841c8 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.render-wasm.api.select :as wselect] [app.common.render-wasm.svg-derived :as svg-derived] [app.common.transit :as t] [app.common.types.shape :as shape] @@ -321,7 +322,7 @@ (let [shape-id (dm/get-prop shape :id)] (if (shape-in-current-page? shape-id) (do - (api/use-shape shape-id) + (wselect/use-shape shape-id) (->> properties (mapcat #(set-wasm-attr! shape %)) (d/index-by :key :callback) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index 82d4f8ab99..1c955c4d82 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -20,6 +20,7 @@ verify the WASM code path was exercised." (:require [app.common.data :as d] + [app.common.render-wasm.api.select :as wselect] [app.render-wasm.api :as wasm.api] [app.render-wasm.api.fonts :as wasm.fonts])) @@ -198,7 +199,7 @@ ;; Save originals (reset! originals {:initialized? wasm.api/initialized? - :use-shape wasm.api/use-shape + :use-shape wselect/use-shape :calculate-position-data wasm.api/calculate-position-data :request-render wasm.api/request-render :clean-modifiers wasm.api/clean-modifiers @@ -217,7 +218,7 @@ :get-content-fonts wasm.fonts/get-content-fonts}) ;; Install mocks (set! wasm.api/initialized? mock-initialized?) - (set! wasm.api/use-shape mock-use-shape) + (set! wselect/use-shape mock-use-shape) (set! wasm.api/calculate-position-data mock-calculate-position-data) (set! wasm.api/request-render mock-request-render) (set! wasm.api/clean-modifiers mock-clean-modifiers) @@ -248,7 +249,7 @@ [] (when-let [orig (not-empty @originals)] (set! wasm.api/initialized? (:initialized? orig)) - (set! wasm.api/use-shape (:use-shape orig)) + (set! wselect/use-shape (:use-shape orig)) (set! wasm.api/calculate-position-data (:calculate-position-data orig)) (set! wasm.api/request-render (:request-render orig)) (set! wasm.api/clean-modifiers (:clean-modifiers orig)) diff --git a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs index c067793ab9..cb5438d1ce 100644 --- a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs @@ -15,6 +15,7 @@ render output below the FFI line needs headed or exporter pixel runs." (:require [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.select :as wselect] [app.common.render-wasm.api.upload :as upload] [app.common.render-wasm.serialize-shape :as serialize-shape] [app.common.render-wasm.svg-derived :as svg-derived] @@ -22,22 +23,26 @@ (defn- with-ffi-stubs* "Stubs the single-arity FFI boundary fns (`flush-shapes-batch!`, - `set-shape-svg-attrs`, `set-shape-path-content`). `set-shape-upload!` is - deliberately left real: it is a trivial one-liner over the stubbed flush, - so the single test exercises its actual delegation and default opts. A - plain fn `set!` onto a multi-arity var breaks under the `:esm` test build - (call sites dispatch via `cljs$core$IFn$_invoke$arity$N`; see `mock/stub`)." + `use-shape!`, `set-shape-svg-attrs`, `set-shape-path-content`). + `set-shape-upload!` is deliberately left real: it is a trivial one-liner + over the stubbed flush, so the single test exercises its actual delegation + and default opts. A plain fn `set!` onto a multi-arity var breaks under + the `:esm` test build (call sites dispatch via + `cljs$core$IFn$_invoke$arity$N`; see `mock/stub`)." [stubs thunk] (let [orig-flush upload/flush-shapes-batch! + orig-select wselect/use-shape orig-svg-attrs props/set-shape-svg-attrs orig-path props/set-shape-path-content] (set! upload/flush-shapes-batch! (:flush stubs)) + (set! wselect/use-shape (or (:select stubs) (fn [_] nil))) (set! props/set-shape-svg-attrs (:svg-attrs stubs)) (set! props/set-shape-path-content (:path stubs)) (try (thunk) (finally (set! upload/flush-shapes-batch! orig-flush) + (set! wselect/use-shape orig-select) (set! props/set-shape-svg-attrs orig-svg-attrs) (set! props/set-shape-path-content orig-path))))) @@ -109,14 +114,14 @@ flush-calls (atom []) events (atom []) current (atom nil) - select-fn (fn [id] - (reset! current id) - (swap! events conj [:select id])) stubs {:flush (fn [s o] (swap! flush-calls conj {:shapes s :opts o}) nil) + :select (fn [id] + (reset! current id) + (swap! events conj [:select id])) :svg-attrs (fn [attrs] (swap! events conj [:svg-attrs @current attrs]) nil) :path (fn [content] (swap! events conj [:path @current content]) nil)} result (with-ffi-stubs* stubs - #(serialize-shape/serialize-shapes-batch! shapes opts select-fn)) + #(serialize-shape/serialize-shapes-batch! shapes opts)) flushed (first @flush-calls)] (t/is (= 1 (count @flush-calls)) "exactly one flush") (t/is (= opts (:opts flushed)) "flush carries passed opts") @@ -152,13 +157,11 @@ flush-calls (atom []) selected (atom []) stubs {:flush (fn [s o] (swap! flush-calls conj {:shapes s :opts o}) nil) + :select (fn [id] (swap! selected conj id)) :svg-attrs (fn [_] (t/is false "no svg-attrs write on empty batch") nil) :path (fn [_] (t/is false "no path write on empty batch") nil)} result (with-ffi-stubs* stubs - #(serialize-shape/serialize-shapes-batch! - [] - opts - (fn [id] (swap! selected conj id))))] + #(serialize-shape/serialize-shapes-batch! [] opts))] (t/is (= [] result) "returns empty prepared") (t/is (empty? @flush-calls) "no flush on empty batch") (t/is (empty? @selected) "no select on empty batch"))) From eb2576b8c03d59b9049a754a190299edc9c58a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 28 Sep 2026 21:06:06 +0200 Subject: [PATCH 003/325] :construction_worker: Run plugins package deploy inside devenv container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel deploy jobs on the shared self-hosted host raced on /home/runner/setup-pnpm (ENOTEMPTY / missing tarball errors). Use penpotapp/devenv, which already ships the Node and pnpm versions pinned in develop, and mount a persistent pnpm store from the host instead of pnpm/setup + actions/cache. Signed-off-by: David Barragán Merino --- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-package.yml | 34 ++++++------------- .../workflows/plugins-deploy-styles-doc.yml | 2 +- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 12e6352688..dc74f61fdd 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -120,7 +120,7 @@ jobs: EOF - name: Deploy to Cloudflare Workers - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: workingDirectory: plugins apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index c8422566d8..8a65ef686f 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -35,6 +35,15 @@ permissions: jobs: deploy: runs-on: penpot-standar-runner + container: + image: penpotapp/devenv:latest + volumes: + # Persistent pnpm store shared by all runner instances on the host. + # pnpm's content-addressable store is safe for concurrent access. + - /var/cache/github-runner/pnpm-store:/root/.local/share/pnpm/store + defaults: + run: + shell: bash env: GH_REF: ${{ inputs.gh_ref }} PLUGIN_NAME: ${{ inputs.plugin_name }} @@ -55,29 +64,6 @@ jobs: fetch-depth: 0 ref: ${{ inputs.gh_ref }} - # START: Setup Node and PNPM enabling cache - - name: Setup pnpm + Node.js - uses: pnpm/setup@v2 - with: - working-directory: plugins - runtime: node@24.21.0 - install: false - - - name: Get pnpm store path - id: pnpm-store - working-directory: ./plugins - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-store.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - # END: Setup Node and PNPM enabling cache - - name: Install deps working-directory: ./plugins shell: bash @@ -112,7 +98,7 @@ jobs: sed -i "s/WORKER_URI/${WORKER_URI}/g" "apps/${PLUGIN_NAME}-plugin/wrangler.toml" - name: Deploy to Cloudflare Workers - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: workingDirectory: plugins apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 39c14580c2..2d356962bf 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -118,7 +118,7 @@ jobs: EOF - name: Deploy to Cloudflare Workers - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: workingDirectory: plugins apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} From f2c472762d655c74aa4d141d499fcea4533b39f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Ersen?= Date: Mon, 28 Sep 2026 10:44:33 +0200 Subject: [PATCH 004/325] :globe_with_meridians: Add translations for: Turkish Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/ --- frontend/translations/tr.po | 185 +++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 3 deletions(-) diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index 1e7edc84f2..71ac54cd5d 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -1,9 +1,9 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-28 08:13+0000\n" +"PO-Revision-Date: 2026-09-29 08:51+0000\n" "Last-Translator: Oğuz Ersen \n" -"Language-Team: Turkish " -"\n" +"Language-Team: Turkish \n" "Language: tr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -9390,3 +9390,182 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Otomatik kaydedilen sürümler %s gün boyunca saklanacaktır." + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "Seçilen kütüphaneleri bağla" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Dosya adı" + +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "Sonraki dosya" + +#: src/app/main/ui/dashboard/import.cljs:459 +msgid "dashboard.import.resolve-libraries.connect-to" +msgstr "şuna bağla" + +#: src/app/main/ui/dashboard/import.cljs:453 +msgid "dashboard.import.resolve-libraries.original-library" +msgstr "orijinal kütüphane" + +#: src/app/main/ui/dashboard/import.cljs:529 +msgid "dashboard.import.summary.manually-linked" +msgstr "elle bağlandı" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "Yeni" + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "Orijinal" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "Henüz organizasyon yok." + +#: src/app/main/ui/auth/login.cljs:113 +msgid "errors.account-locked" +msgstr "" +"Çok fazla başarısız deneme yapıldı. Hesabınız %s dakika kilitlendi. Lütfen " +"daha sonra tekrar deneyin." + +#: src/app/main/errors.cljs:499 +msgid "errors.connection-error" +msgstr "Sunucuya erişilemiyor. Bağlantınızı gözden geçirin ve tekrar deneyin." + +#: src/app/main/data/persistence.cljs:307 +msgid "errors.save-retrying" +msgstr "Bağlantı kesildi. Değişikliklerinizi kaydetmek için yeniden deneniyor." + +#: src/app/main/ui/inspect/attributes/text.cljs:117 +msgid "inspect.attributes.typography.text-decoration.strikethrough" +msgstr "Üstü çizili" + +#: src/app/main/ui/inspect/right_sidebar.cljs:176 +msgid "inspect.tabs-switcher-label" +msgstr "Sekme incelemeye geç" + +#: src/app/main/ui/dashboard/import.cljs:703 +msgid "labels.skip" +msgstr "Atla" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:562 +msgid "labels.team-management" +msgstr "Takım yönetimi" + +#: src/app/main/ui/workspace/colorpicker.cljs:447 +msgid "workspace.colorpicker.color-picker" +msgstr "Ekrandan renk seç" + +#: src/app/main/ui/workspace/left_header.cljs:125 +msgid "workspace.header.retrying" +msgstr "Kaydetme yeniden deneniyor" + +#: src/app/main/ui/workspace/libraries.cljs:842 +msgid "workspace.libraries.connected-libraries" +msgstr "Bağlantılı kütüphane" + +#: src/app/main/ui/workspace/libraries.cljs:183 +msgid "workspace.libraries.connected-through" +msgstr "Şununla bağlandı" + +#: src/app/main/ui/workspace/libraries.cljs:812 +msgid "workspace.libraries.empty.no-assets" +msgstr "Henüz varlık yok" + +#: src/app/main/ui/workspace/libraries.cljs:895 +msgid "workspace.libraries.empty.no-connected-libraries" +msgstr "Henüz bağlantılı kütüphane yok." + +#: src/app/main/ui/workspace/libraries.cljs:827, src/app/main/ui/workspace/libraries.cljs:874 +msgid "workspace.libraries.set-as-tokens-source" +msgstr "Token kaynağı olarak ayarla" + +#: src/app/main/ui/workspace/libraries.cljs:621 +msgid "workspace.libraries.shared-library-added" +msgstr "Eklendi" + +#: src/app/main/ui/workspace/libraries.cljs:167, src/app/main/ui/workspace/libraries.cljs:211 +msgid "workspace.libraries.token-sets" +msgid_plural "workspace.libraries.token-sets" +msgstr[0] "1 küme" +msgstr[1] "%s küme" + +#: src/app/main/ui/workspace/libraries.cljs:170, src/app/main/ui/workspace/libraries.cljs:215 +msgid "workspace.libraries.token-themes" +msgid_plural "workspace.libraries.token-themes" +msgstr[0] "1 tema" +msgstr[1] "%s tema" + +#: src/app/main/ui/workspace/libraries.cljs:164, src/app/main/ui/workspace/libraries.cljs:207 +msgid "workspace.libraries.tokens" +msgid_plural "workspace.libraries.tokens" +msgstr[0] "1 token" +msgstr[1] "%s token" + +#: src/app/main/ui/workspace/libraries.cljs:820, src/app/main/ui/workspace/libraries.cljs:866 +msgid "workspace.libraries.tokens-source" +msgstr "Token kaynağı" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:75 +msgid "workspace.options.canvas-background-section" +msgstr "Tuval arka plan bölümü" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:526 +msgid "workspace.options.font-not-available" +msgstr "" +"Bu metinde kullanılan yazı tipi (%s) artık kullanılabilir değil. Bir " +"tipografi biçimi oluşturmak için yazı tipini yeniden yükleyin" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:99, src/app/main/ui/workspace/sidebar/options/page.cljs:106 +msgid "workspace.options.pixel-grid-color" +msgstr "Piksel ızgara rengi" + +#: src/app/main/data/plugins.cljs:158 +msgid "workspace.plugins.error.unreachable" +msgstr "Eklenti URL'sine erişilemedi." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:106 +msgid "workspace.tokens.add-set" +msgstr "Küme ekle" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:98 +msgid "workspace.tokens.change-token-source" +msgstr "Token kaynağını değiştir" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:101 +msgid "workspace.tokens.current-tokens-source-tooltip" +msgstr "Bu geçerli token kaynağıdır" + +#: src/app/main/data/workspace/libraries.cljs:847 +msgid "workspace.tokens.notifications.source-sets-or-themes-updated" +msgstr "Token kaynağı kümeleri veya temaları değiştirildi" + +#: src/app/main/ui/workspace/colorpicker.cljs:438, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:67 +msgid "workspace.tokens.only-first-fill-or-stroke" +msgstr "" +"Tasarım tokenleri, listedeki yalnızca ilk dolgu veya çerçeveye uygulanabilir." + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:104 +msgid "workspace.tokens.open-source-new-tab" +msgstr "Tokenlerin kaynağını yeni sekmede aç" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:75 +msgid "workspace.tokens.source" +msgstr "Kaynak:" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:311, src/app/main/ui/workspace/tokens/management/context_menu.cljs:335 +msgid "workspace.tokens.stroke-width" +msgstr "Çerçeve Genişliği" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:80 +msgid "workspace.tokens.this-file" +msgstr "Bu dosya" + +#: src/app/main/ui/workspace/top_toolbar.cljs:413 +msgid "workspace.toolbar.label" +msgstr "Çizim araçları" From 90ab142f003499afe383ebdd9e50ede6ac31843a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Tue, 29 Sep 2026 16:05:27 +0200 Subject: [PATCH 005/325] :bug: Add nitrate management methods (#11962) --- backend/src/app/rpc/management/nitrate.clj | 59 +++++++++++++++ .../rpc_management_nitrate_test.clj | 73 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 36e37aca9d..23a11f8151 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -78,6 +78,13 @@ AND t.is_default IS FALSE AND t.deleted_at IS NULL;") +(def ^:private sql:get-member-teams + "SELECT t.id, t.name, t.is_default + FROM team AS t + JOIN team_profile_rel AS tpr ON t.id = tpr.team_id + WHERE tpr.profile_id = ? + AND t.deleted_at IS NULL;") + ;; ---- API: get-penpot-version (def ^:private schema:get-penpot-version-result @@ -107,6 +114,13 @@ (def ^:private schema:get-teams-result [:vector schema:team]) +(def ^:private schema:get-member-teams-result + [:vector + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:is-default ::sm/boolean]]]) + (sv/defmethod ::get-teams "List teams for which current user is owner" {::doc/added "2.18" @@ -118,6 +132,51 @@ (->> (db/exec! cfg [sql:get-teams current-user-id]) (map #(select-keys % [:id :name]))))) +(sv/defmethod ::get-member-teams + "List all active teams where the current user is a member" + {::doc/added "2.18" + ::sm/params [:map] + ::sm/result schema:get-member-teams-result + ::nitrate/sso false} + [cfg {:keys [::rpc/profile-id]}] + (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] + (db/exec! cfg [sql:get-member-teams current-user-id]))) + +;; ---- API: update-profile-theme + +(def ^:private schema:update-profile-theme + [:map {:title "update-profile-theme"} + [:theme [:enum "light" "dark" "system"]]]) + +(sv/defmethod ::update-profile-theme + "Update the current user's theme" + {::doc/added "2.18" + ::sm/params schema:update-profile-theme + ::db/transaction true + ::nitrate/sso false} + [{:keys [::db/conn]} {:keys [::rpc/profile-id theme]}] + (profile/get-profile conn profile-id ::db/for-update true) + (db/update! conn :profile + {:theme theme} + {:id profile-id} + {::db/return-keys false}) + nil) + +;; ---- API: update-profile-props + +(def ^:private schema:update-profile-props + [:map {:title "update-profile-props"} + [:props profile/schema:props-writeable]]) + +(sv/defmethod ::update-profile-props + "Merge writable properties into the current user's profile" + {::doc/added "2.18" + ::sm/params schema:update-profile-props + ::db/transaction true + ::nitrate/sso false} + [cfg {:keys [::rpc/profile-id props]}] + (profile/update-profile-props cfg profile-id props)) + ;; ---- API: upload-organization-logo (def ^:private schema:upload-organization-logo diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 3aa81b7a2b..68fa47bbdd 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -17,6 +17,7 @@ [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] [app.util.ssrf :as ssrf] [app.worker :as wrk] [backend-tests.helpers :as th] @@ -225,6 +226,78 @@ (t/is (= #{(:name owned-team)} (->> out :result (map :name) set)))))) +(t/deftest get-member-teams-returns-all-active-memberships + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + default-team (th/db-get :team {:id (:default-team-id profile)}) + owned-team (th/create-team* 1 {:profile-id (:id profile)}) + member-team (th/create-team* 2 {:profile-id (:id other)}) + _ (th/create-team-role* {:team-id (:id member-team) + :profile-id (:id profile) + :role :editor}) + deleted-team (th/create-team* 3 {:profile-id (:id profile)}) + _ (th/db-update! :team + {:deleted-at (ct/now)} + {:id (:id deleted-team)}) + out (th/management-command! {::th/type :get-member-teams + ::rpc/profile-id (:id profile)})] + (t/is (th/success? out)) + (let [teams-by-id (->> out :result (d/index-by :id))] + (t/is (= #{(:id default-team) (:id owned-team) (:id member-team)} + (set (keys teams-by-id)))) + (t/is (true? (get-in teams-by-id [(:id default-team) :is-default]))) + (t/is (false? (get-in teams-by-id [(:id owned-team) :is-default]))) + (t/is (false? (get-in teams-by-id [(:id member-team) :is-default]))))))) + +(t/deftest update-profile-theme-updates-only-theme + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Nitrate User" + :lang "es" + :theme "light"}) + _ (th/db-update! :profile + {:lang "es"} + {:id (:id profile)}) + out (th/management-command! {::th/type :update-profile-theme + ::rpc/profile-id (:id profile) + :theme "dark"}) + saved (-> (th/db-get :profile {:id (:id profile)}) + (profile/decode-row))] + (t/is (th/success? out)) + (t/is (= "dark" (:theme saved))) + (t/is (= "Nitrate User" (:fullname saved))) + (t/is (= "es" (:lang saved)))))) + +(t/deftest update-profile-theme-rejects-invalid-theme + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/management-command! {::th/type :update-profile-theme + ::rpc/profile-id (:id profile) + :theme "invalid"}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation))))) + +(t/deftest update-profile-props-merges-onboarding-props + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/management-command! {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:nitrate-onboarding-viewed true + :onboarding-questions-answered true + :onboarding-questions + {:role "developer" + :company-size "2-100"}}}) + saved (-> (th/db-get :profile {:id (:id profile)}) + (profile/decode-row))] + (t/is (th/success? out)) + (t/is (true? (get-in saved [:props :nitrate-onboarding-viewed]))) + (t/is (true? (get-in saved [:props :onboarding-questions-answered]))) + (t/is (= {:role "developer" :company-size "2-100"} + (get-in saved [:props :onboarding-questions])))))) + (t/deftest notify-team-change-publishes-event (let [team-id (uuid/random) organization-id (uuid/random) From 193ca4b0d25fe5eeecc49e7712739b97b2f6074f Mon Sep 17 00:00:00 2001 From: imagoiq Date: Wed, 30 Sep 2026 13:13:41 +0200 Subject: [PATCH 006/325] :globe_with_meridians: Add translations for: French Currently translated at 98.1% (2234 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/ --- frontend/translations/fr.po | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 2c4043ff30..24bb023ce1 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,9 +1,9 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-28 08:13+0000\n" -"Last-Translator: Surfoo \n" -"Language-Team: French " -"\n" +"PO-Revision-Date: 2026-09-30 16:51+0000\n" +"Last-Translator: imagoiq \n" +"Language-Team: French \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -611,7 +611,7 @@ msgstr "Aucune police personnalisée pour l'instant." #: src/app/main/ui/dashboard/fonts.cljs:499 msgid "dashboard.fonts.empty-placeholder-viewer-sub" msgstr "" -"Dès qu'un membre de l'équipe télécharge une police personnalisée, elle sera " +"Dès qu'un membre de l'équipe téléverse une police personnalisée, elle sera " "affichée ici." #: src/app/main/ui/dashboard/fonts.cljs:223 @@ -624,25 +624,25 @@ msgstr[1] "%s polices ajoutées" #, markdown msgid "dashboard.fonts.hero-text1" msgstr "" -"Toute police Web que vous téléchargez sera ajoutée à la liste de polices de " +"Toute police Web que vous téléversez sera ajoutée à la liste de polices de " "caractères disponible dans les propriétés de texte des fichiers de cette " "équipe. Les polices du même nom de famille de polices seront groupées en " "**une seule famille de polices**. Vous pouvez télécharger les polices au " -"formats suivants : **TTF, OTF et WOFF** (un seul format est nécessaire)." +"formats suivants : **TTF, OTF et WOFF** (un seul format est nécessaire)." #: src/app/main/ui/dashboard/fonts.cljs:211 #, markdown msgid "dashboard.fonts.hero-text2" msgstr "" -"Ne téléchargez que des polices que vous possédez ou dont la licence vous " +"Ne téléversez que des polices que vous possédez ou dont la licence vous " "permet de les utiliser dans Penpot. Vous trouverez plus d'informations dans " -"la section Propriété des Contenus des [conditions générales d'utilisation " -"de Penpot](%s). Vous pouvez également vous renseigner sur les [licences de " +"la section Propriété des Contenus des [conditions générales d'utilisation de " +"Penpot](%s). Vous pouvez également vous renseigner sur les [licences de " "polices](https://www.typography.com/faq)." #: src/app/main/ui/dashboard/fonts.cljs:231 msgid "dashboard.fonts.upload-all" -msgstr "Uploader tout" +msgstr "Tout téléverser" #: src/app/main/ui/dashboard/fonts.cljs:216 #, markdown @@ -1497,7 +1497,7 @@ msgstr "Les polices %s n'ont pas pu être chargées" #: src/app/main/data/workspace/media.cljs:237 msgid "errors.cannot-upload" -msgstr "Impossible de télécharger le fichier média." +msgstr "Impossible de téléverser le fichier média." #: src/app/main/ui/comments.cljs:769, src/app/main/ui/comments.cljs:799, src/app/main/ui/comments.cljs:898 msgid "errors.character-limit-exceeded" @@ -3450,15 +3450,15 @@ msgstr "Mettre à jour l'équipe" #: src/app/main/ui/dashboard/fonts.cljs:270 msgid "labels.upload" -msgstr "Télécharger" +msgstr "Téléverser" #: src/app/main/ui/dashboard/fonts.cljs:197 msgid "labels.upload-custom-fonts" -msgstr "Télécharger des polices personnalisées" +msgstr "Téléverser des polices personnalisées" #: src/app/main/ui/dashboard/fonts.cljs:269 msgid "labels.uploading" -msgstr "Téléchargement…" +msgstr "Envoi en cours…" #: src/app/main/ui/dashboard/import.cljs:648 msgid "labels.uploading-file" @@ -9512,3 +9512,9 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Les versions auto-enregistrées seront conservées %s jours." + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:526 +msgid "workspace.options.font-not-available" +msgstr "" +"La police utilisée par ce texte (%s) n'est plus disponible. Téléverser à " +"nouveau la police pour créer un style typographique" From b31bcd95f40959db6aa5c99b9fd9c05927bef649 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Tue, 29 Sep 2026 18:59:51 +0200 Subject: [PATCH 007/325] :globe_with_meridians: Add translations for: Dutch Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/ --- frontend/translations/nl.po | 191 ++++++++++++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 7 deletions(-) diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 914584f564..5c7ffa4b37 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -1,9 +1,9 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-28 08:14+0000\n" +"PO-Revision-Date: 2026-09-30 16:51+0000\n" "Last-Translator: Stephan Paternotte \n" -"Language-Team: Dutch " -"\n" +"Language-Team: Dutch \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -2235,7 +2235,7 @@ msgstr "Geen" #: src/app/main/ui/inspect/attributes/text.cljs:117 msgid "inspect.attributes.typography.text-decoration.strikethrough" -msgstr "" +msgstr "Doorhalen" #: src/app/main/ui/inspect/attributes/text.cljs:115 msgid "inspect.attributes.typography.text-decoration.underline" @@ -4204,11 +4204,11 @@ msgstr "" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:121 msgid "nitrate.code-activation.footer-after" -msgstr " en neem contact met ons op:" +msgstr "Stuur het bestand naar" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:126 msgid "nitrate.code-activation.footer-before" -msgstr "Een code nodig? Download je " +msgstr "en wij sturen je je code." #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:119 msgid "nitrate.code-activation.footer-download" @@ -6945,7 +6945,7 @@ msgstr "Annuleren" #: src/app/main/ui/workspace/sidebar/options/page.cljs:78, src/app/main/ui/workspace/sidebar/options/page.cljs:86 msgid "workspace.options.canvas-background" -msgstr "Canvasachtergrond" +msgstr "Doekachtergrond" #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:722 msgid "workspace.options.clip-content" @@ -9447,3 +9447,180 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Automatisch opgeslagen versies worden %s dagen bewaard." + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "Geselecteerde bibliotheken verbinden" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Bestandsnaam" + +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "Volgende bestand" + +#: src/app/main/ui/dashboard/import.cljs:459 +msgid "dashboard.import.resolve-libraries.connect-to" +msgstr "verbinden met" + +#: src/app/main/ui/dashboard/import.cljs:453 +msgid "dashboard.import.resolve-libraries.original-library" +msgstr "originele bibliotheek" + +#: src/app/main/ui/dashboard/import.cljs:529 +msgid "dashboard.import.summary.manually-linked" +msgstr "handmatig gekoppeld" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "Nieuw" + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "Origineel" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "Nog geen organisaties." + +#: src/app/main/ui/auth/login.cljs:113 +msgid "errors.account-locked" +msgstr "" +"Te veel mislukte pogingen. Uw account is voor %s minuten vergrendeld. " +"Probeer het later nog eens." + +#: src/app/main/errors.cljs:499 +msgid "errors.connection-error" +msgstr "" +"Kan de server niet bereiken Controleer uw verbinding en probeer het opnieuw." + +#: src/app/main/data/persistence.cljs:307 +msgid "errors.save-retrying" +msgstr "Verbinding verbroken. Opnieuw proberen om uw wijzigingen op te slaan." + +#: src/app/main/ui/dashboard/import.cljs:703 +msgid "labels.skip" +msgstr "Overslaan" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:562 +msgid "labels.team-management" +msgstr "Team management" + +#: src/app/main/ui/workspace/colorpicker.cljs:447 +msgid "workspace.colorpicker.color-picker" +msgstr "Kies kleur van scherm" + +#: src/app/main/ui/workspace/left_header.cljs:125 +msgid "workspace.header.retrying" +msgstr "Opnieuw proberen op te slaan" + +#: src/app/main/ui/workspace/libraries.cljs:842 +msgid "workspace.libraries.connected-libraries" +msgstr "Verbonden bibliotheek" + +#: src/app/main/ui/workspace/libraries.cljs:183 +msgid "workspace.libraries.connected-through" +msgstr "Verbinden door" + +#: src/app/main/ui/workspace/libraries.cljs:812 +msgid "workspace.libraries.empty.no-assets" +msgstr "Nog geen activa" + +#: src/app/main/ui/workspace/libraries.cljs:895 +msgid "workspace.libraries.empty.no-connected-libraries" +msgstr "Nog geen verbonden bibliotheken." + +#: src/app/main/ui/workspace/libraries.cljs:827, src/app/main/ui/workspace/libraries.cljs:874 +msgid "workspace.libraries.set-as-tokens-source" +msgstr "Instellen als tokensbron" + +#: src/app/main/ui/workspace/libraries.cljs:621 +msgid "workspace.libraries.shared-library-added" +msgstr "Toegevoegd" + +#: src/app/main/ui/workspace/libraries.cljs:167, src/app/main/ui/workspace/libraries.cljs:211 +msgid "workspace.libraries.token-sets" +msgid_plural "workspace.libraries.token-sets" +msgstr[0] "1 set" +msgstr[1] "%s sets" + +#: src/app/main/ui/workspace/libraries.cljs:170, src/app/main/ui/workspace/libraries.cljs:215 +msgid "workspace.libraries.token-themes" +msgid_plural "workspace.libraries.token-themes" +msgstr[0] "1 thema" +msgstr[1] "%s thema's" + +#: src/app/main/ui/workspace/libraries.cljs:164, src/app/main/ui/workspace/libraries.cljs:207 +msgid "workspace.libraries.tokens" +msgid_plural "workspace.libraries.tokens" +msgstr[0] "1 token" +msgstr[1] "%s tokens" + +#: src/app/main/ui/workspace/libraries.cljs:820, src/app/main/ui/workspace/libraries.cljs:866 +msgid "workspace.libraries.tokens-source" +msgstr "Tokensbron" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:75 +msgid "workspace.options.canvas-background-section" +msgstr "Doekachtergrond-sectie" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:526 +msgid "workspace.options.font-not-available" +msgstr "" +"Het lettertype dat door deze tekst wordt gebruikt (%s) is niet langer " +"beschikbaar. Upload het lettertype opnieuw om een typografiestijl te maken" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:99, src/app/main/ui/workspace/sidebar/options/page.cljs:106 +msgid "workspace.options.pixel-grid-color" +msgstr "Pixelrasterkleur" + +#: src/app/main/data/plugins.cljs:158 +msgid "workspace.plugins.error.unreachable" +msgstr "De URL van de plugin kon niet worden bereikt." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:106 +msgid "workspace.tokens.add-set" +msgstr "Set toevoegen" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:98 +msgid "workspace.tokens.change-token-source" +msgstr "Tokenbron wijzigen" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:101 +msgid "workspace.tokens.current-tokens-source-tooltip" +msgstr "Dit is de huidige tokensbron" + +#: src/app/main/data/workspace/libraries.cljs:847 +msgid "workspace.tokens.notifications.source-sets-or-themes-updated" +msgstr "Tokensbronnensets of thema's zijn aangepast" + +#: src/app/main/ui/workspace/colorpicker.cljs:438, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:67 +msgid "workspace.tokens.only-first-fill-or-stroke" +msgstr "" +"Ontwerptokens kunnen alleen worden toegepast op de eerste vulling of streek " +"in de lijst." + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:104 +msgid "workspace.tokens.open-source-new-tab" +msgstr "Open de tokensbron in een nieuw tabblad" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:75 +msgid "workspace.tokens.source" +msgstr "Bron:" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:311, src/app/main/ui/workspace/tokens/management/context_menu.cljs:335 +msgid "workspace.tokens.stroke-width" +msgstr "Streekdikte" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:80 +msgid "workspace.tokens.this-file" +msgstr "Dit bestand" + +#: src/app/main/ui/workspace/top_toolbar.cljs:413 +msgid "workspace.toolbar.label" +msgstr "Tekenhulpmiddelen" + +#: src/app/main/ui/inspect/right_sidebar.cljs:176 +msgid "inspect.tabs-switcher-label" +msgstr "Inspectietabblad schakelen" From 970948159ec96a41faf6a0c48ca2cc4612e4c152 Mon Sep 17 00:00:00 2001 From: Shreyash Agare Date: Thu, 1 Oct 2026 13:43:42 +0530 Subject: [PATCH 008/325] :bug: Fix token display for multi-selected text layers (#11944) * :bug: Fix token display for multi-selected text layers When multiple text layers share the same fill token, the design panel showed the hex value instead of the token name. type->token-attrs derived token keys from type->editable-attrs, which returns empty for text shapes in the fill group. Fall back to the group's own attrs when editable-attrs is empty. Closes #11924 AI-assisted-by: claude-opus-4-6 * :bug: Take text token attrs from the group attrs The :text read mode reads values from the group attrs, so its token attrs now come from those attrs too, instead of falling back when the editable attrs are empty. type->token-attrs is restored to its original form. Add regression tests for fill tokens on multiple selections of text shapes, and of a rect mixed with a text. AI-assisted-by: claude-opus-5-5 --------- Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> Co-authored-by: Andrey Antukh --- .../sidebar/options/shapes/multiple.cljs | 12 ++++-- .../ui/layout_container_multiple_test.cljs | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index a5ed1ce76b..8c4cad66b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -283,6 +283,11 @@ (into [] (comp (mapcat tt/shape-attr->token-attrs) (distinct)) (type->editable-attrs type)))) + ;; The `:text` read mode reads values from `attrs`, not from the + ;; editable attrs, so its token attrs must come from `attrs` too. + text-token-attrs + (into [] (comp (mapcat tt/shape-attr->token-attrs) (distinct)) attrs) + merge-attrs (fn [v1 v2] (cond @@ -311,8 +316,9 @@ merge-token-values (fn [acc token-attrs applied-tokens] - "Merges token values across all token attributes derived from the shape's - editable attributes." + "Merges token values across all `token-attrs`. Callers derive them from + the attributes the shape's read mode reads: the editable attributes for + `:shape`, the whole group attributes for `:text`." (let [no-tokens? (empty? applied-tokens) stable (deref stable-token-acc)] (if (and no-tokens? @@ -360,7 +366,7 @@ (merge-attrs shape-attrs) (merge-attrs content-attrs)) - new-token-acc (merge-token-values token-acc (type->token-attrs type) applied-tokens)] + new-token-acc (merge-token-values token-acc text-token-attrs applied-tokens)] [(conj ids id) new-values new-token-acc]) diff --git a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs index 10b64d95b1..77bc263fa3 100644 --- a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs +++ b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs @@ -44,3 +44,41 @@ (t/is (= {:p1 10 :p2 20 :p3 10 :p4 20} (:layout-padding values))) (t/is (= :multiple (:layout-padding-type values))))) + +(defn- fill-tokens + [shapes] + (let [[_ids _values tokens] (multiple/get-attrs* shapes {} :fill)] + tokens)) + +(defn- text-shape + [id applied-tokens] + (cond-> {:id id :type :text :fills []} + applied-tokens (assoc :applied-tokens applied-tokens))) + +(defn- rect-shape + [id applied-tokens] + (cond-> {:id id :type :rect :fills []} + applied-tokens (assoc :applied-tokens applied-tokens))) + +(t/deftest multiple-text-selection-same-fill-token + (t/is (= {:fill "color.primary"} + (fill-tokens [(text-shape :text-1 {:fill "color.primary"}) + (text-shape :text-2 {:fill "color.primary"})])))) + +(t/deftest multiple-text-selection-different-fill-tokens + (t/is (= {:fill :multiple} + (fill-tokens [(text-shape :text-1 {:fill "color.primary"}) + (text-shape :text-2 {:fill "color.secondary"})])))) + +(t/deftest multiple-text-selection-token-and-no-token + (t/is (= {:fill :multiple} + (fill-tokens [(text-shape :text-1 {:fill "color.primary"}) + (text-shape :text-2 nil)])))) + +(t/deftest multiple-selection-rect-and-text-fill-tokens + (t/is (= {:fill "color.primary"} + (fill-tokens [(rect-shape :rect-1 {:fill "color.primary"}) + (text-shape :text-1 {:fill "color.primary"})]))) + (t/is (= {:fill :multiple} + (fill-tokens [(rect-shape :rect-1 {:fill "color.primary"}) + (text-shape :text-1 {:fill "color.secondary"})])))) From 9111ebb3bfee9167b72a5a44e2c8441781fe03c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 1 Oct 2026 13:03:55 +0200 Subject: [PATCH 009/325] :sparkles: Add same-subscription option to move-teams permission (#11995) --- .../test/backend_tests/rpc_nitrate_test.clj | 84 +++++++++++++++++++ common/src/app/common/types/organization.cljc | 10 +++ .../common_tests/types/organization_test.cljc | 27 ++++-- frontend/src/app/main/data/nitrate.cljs | 16 +++- .../frontend_tests/data/nitrate_test.cljs | 42 ++++++++++ 5 files changed, 172 insertions(+), 7 deletions(-) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 978a91ca19..1d279f7fda 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -1336,6 +1336,90 @@ (t/is (th/success? out)) (t/is (empty? @sent)))))) +;; The move-teams tests below simulate a second session: Nitrate answers +;; with the permissions as they are at the moment of the call, as if the +;; organization owner had changed them after the user opened the modal. + +(defn- move-team-nitrate-mock + [{:keys [owner-id team-id source-organization perms-by-organization-id calls]}] + (fn [_cfg method params] + (swap! calls conj method) + (case method + :get-organization-membership {:is-member true :organization-id (:organization-id params)} + :get-organization-members [owner-id] + :get-team-organization {:organization source-organization} + :get-organization-permissions (get perms-by-organization-id (:organization-id params)) + :set-team-organization {:id team-id} + :get-organization-sso {:active false} + :get-organization-summary {:id (:organization-id params) :teams [{:id team-id}]} + nil))) + +(t/deftest remove-team-from-organization-follows-move-teams + (doseq [[i move-teams allowed?] [[401 "always" true] + [402 "myOrganizations" false] + [403 "never" false]]] + (t/testing move-teams + (let [owner (th/create-profile* i {:is-active true}) + team (th/create-team* i {:profile-id (:id owner)}) + organization-id (uuid/random) + calls (atom [])] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (move-team-nitrate-mock + {:owner-id (:id owner) + :team-id (:id team) + :perms-by-organization-id + {organization-id {:owner-id (uuid/random) + :permissions {:move-teams move-teams}}} + :calls calls})] + (let [out (th/command! {::th/type :remove-team-from-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id organization-id + :organization-name "Org"})] + (if allowed? + (t/is (th/success? out)) + (t/is (= :not-allowed (th/ex-code (:error out))))) + (t/is (= allowed? + (contains? (set @calls) :remove-team-from-organization))))))))) + +(t/deftest add-team-to-organization-move-follows-move-teams + (doseq [[i desc move-teams same-owner? requester-owns-organization? allowed?] + [[411 "myOrganizations, same owner" "myOrganizations" true false true] + [412 "myOrganizations, other owner" "myOrganizations" false false false] + [413 "never" "never" true false false] + [414 "never, organization owner" "never" true true false]]] + (t/testing desc + (let [owner (th/create-profile* i {:is-active true}) + team (th/create-team* i {:profile-id (:id owner)}) + source-id (uuid/random) + target-id (uuid/random) + source-owner-id (if requester-owns-organization? (:id owner) (uuid/random)) + target-owner-id (if same-owner? source-owner-id (uuid/random)) + calls (atom [])] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (move-team-nitrate-mock + {:owner-id (:id owner) + :team-id (:id team) + :source-organization {:id source-id} + :perms-by-organization-id + {source-id {:owner-id source-owner-id + :permissions {:create-teams "any" + :move-teams move-teams}} + target-id {:owner-id target-owner-id + :permissions {:create-teams "any" + :move-teams "always"}}} + :calls calls}) + teams/initialize-user-in-organization (fn [& _] nil)] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id target-id})] + (if allowed? + (t/is (th/success? out)) + (t/is (= :not-allowed (th/ex-code (:error out))))) + (t/is (= allowed? + (contains? (set @calls) :set-team-organization))))))))) + (t/deftest get-nitrate-activation-code-request (let [profile (th/create-profile* 1 {:is-active true}) nitrate-id "nitrate-instance-1" diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index f601cf2971..380832fe00 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -100,6 +100,16 @@ :else false)) (defn- can-move-team? + "Checks the source organization's `:move-teams` value. The check that + the user owns the team happens elsewhere. + + - \"always\": the team can move to any organization, or be taken out + of the organization. + - \"myOrganizations\": the team can move only to an organization with + the same owner, and cannot be taken out. A person has one Enterprise + subscription, so the same owner means the same subscription. + - \"never\": only the Admin Console can move teams. Nobody moves them + from Penpot, not even the organization owner." [{:keys [permission-value target-organization-same-owner?]}] (cond (= permission-value "never") diff --git a/common/test/common_tests/types/organization_test.cljc b/common/test/common_tests/types/organization_test.cljc index 1d2c48be01..e4907a26d7 100644 --- a/common/test/common_tests/types/organization_test.cljc +++ b/common/test/common_tests/types/organization_test.cljc @@ -73,12 +73,12 @@ (let [always-organization (assoc organization-perms :permissions {:create-teams "any" :delete-teams "onlyOwners" :move-teams "always"})] - ;; Organization owner should always be allowed + ;; Team owners can move the team anywhere, or take it out (t/is (true? (cto/allowed? :move-team {:organization-perms always-organization :profile-id :owner :team-perms {}}))) - ;; Regular member should be allowed when move-teams is "always" + ;; Being the organization owner makes no difference (t/is (true? (cto/allowed? :move-team {:organization-perms always-organization :profile-id :member @@ -88,7 +88,8 @@ (let [my-organizations (assoc organization-perms :permissions {:create-teams "any" :delete-teams "onlyOwners" :move-teams "myOrganizations"})] - ;; Organization owner must also stay within same-owner organizations + ;; Same owner means same subscription: the organization owner is + ;; bound by it too (t/is (false? (cto/allowed? :move-team {:organization-perms my-organizations :profile-id :owner @@ -99,7 +100,7 @@ :profile-id :owner :team-perms {} :target-organization-same-owner? true}))) - ;; Regular member should be allowed only if target has same owner + ;; A team owner who is not the organization owner, same rule (t/is (true? (cto/allowed? :move-team {:organization-perms my-organizations :profile-id :member @@ -111,11 +112,27 @@ :team-perms {} :target-organization-same-owner? false}))))) +(t/deftest move-team-myorganizations-denies-taking-the-team-out + (let [my-organizations (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "myOrganizations"})] + ;; Taking a team out has no target organization, so no same owner + (t/is (false? (cto/allowed? :move-team + {:organization-perms my-organizations + :profile-id :owner + :team-perms {}}))) + (t/is (false? (cto/allowed? :move-team + {:organization-perms my-organizations + :profile-id :member + :team-perms {} + :target-organization-same-owner? nil}))))) + (t/deftest move-team-never-denies-all (let [never-organization (assoc organization-perms :permissions {:create-teams "any" :delete-teams "onlyOwners" :move-teams "never"})] - ;; Even organization owner should be denied + ;; Only the Admin Console moves teams: the organization owner is + ;; denied in Penpot too (t/is (false? (cto/allowed? :move-team {:organization-perms never-organization :profile-id :owner diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 67ddad852e..7c3efefb40 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -414,7 +414,18 @@ :subscription-status subscription-status}) pending-id (str (uuid/next)) callback-url (dom/append-query-param (rt/get-current-href) - :pending-action-id pending-id)] + :pending-action-id pending-id) + ;; The permissions may have changed in Nitrate since the modal + ;; was opened: refresh them and explain why the action failed. + on-error + (fn [cause] + (if (= :not-allowed (-> cause ex-data :code)) + (rx/of (dt/fetch-teams) + (modal/show :no-permission-modal + {:type (if (= team-previous-organization-status "other-organization") + :no-organizations-change + :no-organizations-create)})) + (rx/throw cause)))] (rx/concat (when-not skip-audit? (rx/of audit-event)) @@ -423,7 +434,8 @@ (fn [{:keys [authorized redirect-uri]}] (if authorized (->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id}) - (rx/map (fn [_] (modal/hide)))) + (rx/map (fn [_] (modal/hide))) + (rx/catch on-error)) (if redirect-uri (do (ss/save-pending-action! pending-id {:type :add-team-to-organization diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index 365715d2e4..2ea4442157 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -15,10 +15,12 @@ [app.main.data.notifications :as ntf] [app.main.data.team :as dt] [app.main.repo :as rp] + [app.main.router :as rt] [app.main.store :as st] [app.main.ui.auth.verify-token :as verify-token] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) @@ -352,3 +354,43 @@ {:callback callback}) parsed (-> href u/uri :query u/query-string->map :callback)] (t/is (= callback parsed))))) + +(defn- ^:async observe-add-team-rejected-by-permissions + "Runs `add-team-to-organization` for `team` while the backend rejects it + with `:not-allowed`, as when another session changed the permissions + after the modal opened. Resolves to the emitted events." + [team] + (let [emitted (atom []) + event (dnt/add-team-to-organization {:team-id (:id team) + :organization-id "org-2" + :skip-audit? true}) + state {:teams {(:id team) team}}] + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (constantly "http://localhost/#/dashboard")) + rp/cmd! (mock/stub + (fn [cmd _params] + (case cmd + :check-nitrate-sso (rx/of {:authorized true}) + ::dnt/add-team-to-organization + (rx/throw (ex-info "not allowed" {:type :validation :code :not-allowed}))))) + modal/show (mock/stub (fn [& args] {:modal-show args}))} + (await (async/observe (ptk/watch event state nil) + :on-next #(swap! emitted conj %))))) + @emitted)) + +(t/deftest ^:async add-team-to-organization-shows-no-permission-on-stale-move + (let [emitted (await (observe-add-team-rejected-by-permissions + {:id "team-1" :organization {:id "org-1"}}))] + (t/is (= 2 (count emitted))) + (t/is (= ::dt/fetch-teams (ptk/type (first emitted)))) + (t/is (= {:modal-show [:no-permission-modal {:type :no-organizations-change}]} + (second emitted))))) + +(t/deftest ^:async add-team-to-organization-shows-no-permission-on-stale-add + (let [emitted (await (observe-add-team-rejected-by-permissions + {:id "team-1"}))] + (t/is (= 2 (count emitted))) + (t/is (= ::dt/fetch-teams (ptk/type (first emitted)))) + (t/is (= {:modal-show [:no-permission-modal {:type :no-organizations-create}]} + (second emitted))))) From cc2aff6ce8772150ff95d2b684acdda8b87f31b4 Mon Sep 17 00:00:00 2001 From: JiMyung Lee Date: Thu, 1 Oct 2026 23:02:21 +0900 Subject: [PATCH 010/325] :bug: Fix typography sample overflow in non-Latin locales (#11487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix typography sample overflow in non-Latin locales The typography sample glyph sits in a fixed 1.5rem grid column, sized for the English sample "Ag". Locales that translate it to something wider wrap it onto several lines and overflow the row: ko translates the sample as "가나다" and renders it as three stacked characters, ar as "أسلوب خط النص" and renders it as three stacked words. This affects the workspace assets panel (list row, detail panel and rename row) and the dashboard library card. Let the sample column size to its content and keep the sample on a single line. The 1.5rem minimum is preserved, so locales whose sample already fits — English and every locale that keeps "Ag" — render exactly as before. Signed-off-by: JiMyung Lee * :bug: Keep spacing between typography sample and name Follow-up to review on the typography sample overflow fix. Letting the sample grow removed the fixed-width slack that used to separate it from the name text, so: - Dashboard library card: `.library-name-block` no longer assumes a 24px sample via a hard-coded calc; it flexes to the remaining space and the sample does not shrink. - Typography detail panel, list row and rename/advanced-edit row: add an explicit `var(--sp-xs)` gap between the sample and the name, and let the name input shrink instead of pushing the action buttons. - Libraries "Updates" tab: the sample div had no class and still wrapped per character; give it a `nowrap` class. Signed-off-by: JiMyung Lee --------- Signed-off-by: JiMyung Lee Signed-off-by: Eva Marco Co-authored-by: Eva Marco --- frontend/src/app/main/ui/dashboard/grid.scss | 7 +++++-- frontend/src/app/main/ui/workspace/libraries.cljs | 3 ++- frontend/src/app/main/ui/workspace/libraries.scss | 4 ++++ .../sidebar/options/menus/typography.scss | 14 +++++++++++--- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index 11f25f8f86..df8adf08d6 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -308,7 +308,8 @@ $thumbnail-default-height: px2rem(168); .library-name-block { color: var(--color-foreground-secondary); - inline-size: calc(100% - var(--sp-xxl) - var(--sp-s)); + flex: 1; + min-inline-size: 0; text-align: left; } @@ -381,7 +382,9 @@ $thumbnail-default-height: px2rem(168); block-size: px2rem(20); line-height: 1; margin-inline-end: var(--sp-xs); - inline-size: px2rem(20); + flex-shrink: 0; + min-inline-size: px2rem(20); + white-space: nowrap; overflow: hidden; } diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 5c09d951ff..23482a3d69 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -1064,7 +1064,8 @@ [:div {:class (stl/css :libraries-updates-item) :key (dm/str (:id typography))} [:* - [:div {:style {:font-family (:font-family typography) + [:div {:class (stl/css :typography-sample) + :style {:font-family (:font-family typography) :font-weight (:font-weight typography) :font-style (:font-style typography)}} (tr "workspace.assets.typography.sample")] diff --git a/frontend/src/app/main/ui/workspace/libraries.scss b/frontend/src/app/main/ui/workspace/libraries.scss index 87ad47c9a1..6dc6dfcec1 100644 --- a/frontend/src/app/main/ui/workspace/libraries.scss +++ b/frontend/src/app/main/ui/workspace/libraries.scss @@ -242,6 +242,10 @@ min-width: $sz-24; } +.typography-sample { + white-space: nowrap; +} + .name-block { color: var(--library-content-foreground-color); width: px2rem(168); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index bbfa19734e..606303bf47 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -61,7 +61,8 @@ $font-preview-box-height: 28px; .typography-selection-wrapper { display: grid; - grid-template-columns: $sz-24 auto 1fr; + grid-template-columns: minmax($sz-24, auto) auto 1fr; + column-gap: var(--sp-xs); flex: 1; block-size: 100%; inline-size: 100%; @@ -78,6 +79,7 @@ $font-preview-box-height: 28px; align-items: center; min-inline-size: $sz-24; block-size: $sz-32; + white-space: nowrap; color: var(--color-foreground-secondary); } @@ -94,6 +96,7 @@ $font-preview-box-height: 28px; display: flex; align-items: center; + gap: var(--sp-xs); block-size: $sz-32; inline-size: 100%; border-radius: $br-8; @@ -116,9 +119,11 @@ $font-preview-box-height: 28px; display: flex; justify-content: center; align-items: center; - inline-size: $sz-24; + flex-shrink: 0; + min-inline-size: $sz-24; block-size: 100%; font-size: px2rem(16); + white-space: nowrap; line-height: 1; overflow: hidden; color: var(--color-foreground-primary); @@ -128,6 +133,7 @@ $font-preview-box-height: 28px; font-size: px2rem(12); color: var(--font-name-wrapper-foreground-color); flex-grow: 1; + min-inline-size: 0; padding-inline-start: px2rem(6); margin: 0; border: none; @@ -158,7 +164,8 @@ $font-preview-box-height: 28px; @extend %asset-element; display: grid; - grid-template-columns: $sz-24 auto 1fr $sz-28; + grid-template-columns: minmax($sz-24, auto) auto 1fr $sz-28; + column-gap: var(--sp-xs); flex: 1; block-size: $sz-32; inline-size: 100%; @@ -175,6 +182,7 @@ $font-preview-box-height: 28px; font-size: px2rem(16); line-height: 1; block-size: $sz-32; + white-space: nowrap; padding: 0; overflow: hidden; color: var(--color-foreground-primary); From 03e24d18ce64242811978dba02258248b4f5526a Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 1 Oct 2026 17:17:11 +0200 Subject: [PATCH 011/325] :sparkles: Dual-send audit log archive to Nitrate and Nexus (#12037) Run the audit archive cron when :nexus or :admin-console is on. Ship allowlisted events to Admin Console first (with row ids for idempotency), then the full chunk to Nexus when :nexus is set. Mark archived_at for the whole chunk on success, including nitrate-only mode. Rename the gate flag from :audit-log-archive to :nexus. AI-assisted-by: Composer Co-authored-by: Cursor --- .serena/memories/backend/audit-log.md | 16 +- backend/scripts/_env | 2 +- .../src/app/loggers/audit/archive_task.clj | 97 ++++++-- backend/src/app/main.clj | 9 +- backend/src/app/nitrate.clj | 69 ++++-- .../loggers_audit_archive_task_test.clj | 227 ++++++++++++++++++ .../nitrate_ingest_audit_log_test.clj | 81 +++++++ common/src/app/common/flags.cljc | 4 +- 8 files changed, 455 insertions(+), 50 deletions(-) create mode 100644 backend/test/backend_tests/loggers_audit_archive_task_test.clj create mode 100644 backend/test/backend_tests/nitrate_ingest_audit_log_test.clj diff --git a/.serena/memories/backend/audit-log.md b/.serena/memories/backend/audit-log.md index ccd4153655..a77c3e75a4 100644 --- a/.serena/memories/backend/audit-log.md +++ b/.serena/memories/backend/audit-log.md @@ -5,11 +5,11 @@ Penpot records what users do as events in the Postgres `audit_log` table. There ## Purpose - The audit log answers "who did what, when, from where": every RPC mutation and selected frontend actions become a row with `name`, `type`, `profile-id`, `ip-addr`, `props` and `context`. Product analytics, abuse investigation and compliance exports all read from here, so keep events truthful and never put secrets in `props`. -- It is also the trigger bus for side effects: the same event object fans out to webhooks, error reporting and telemetry without the RPC handler knowing. New features should reuse this bus instead of building parallel notification paths. +- It is also the trigger bus for side effects: the same event object fans out to webhooks, error reporting and telemetry without the RPC handler knowing. New features should reuse this bus instead of building parallel notification paths. Archival consumers: Nexus and optionally Nitrate (Admin Console) via the archive task. ## Storage -- Live `audit_log` columns: `id` uuid PK default `gen_random_uuid()`; `name`/`type` text NOT NULL; `created_at` timestamptz NOT NULL default `now()` (server time, the source of truth); `tracked_at` timestamptz default `now()` (client-claimed time, corrected on ingest); `profile_id` uuid NOT NULL; `source` text telling full rows (`backend`/`frontend`) apart from anonymized copies (`telemetry:backend`/`telemetry:frontend`); `ip_addr` inet; `props`/`context` jsonb holding transit-encoded maps; `archived_at` timestamptz set once Nexus acknowledges the row. +- Live `audit_log` columns: `id` uuid PK default `gen_random_uuid()`; `name`/`type` text NOT NULL; `created_at` timestamptz NOT NULL default `now()` (server time, the source of truth); `tracked_at` timestamptz default `now()` (client-claimed time, corrected on ingest); `profile_id` uuid NOT NULL; `source` text telling full rows (`backend`/`frontend`) apart from anonymized copies (`telemetry:backend`/`telemetry:frontend`); `ip_addr` inet; `props`/`context` jsonb holding transit-encoded maps; `archived_at` timestamptz set once a successful archive-task chunk finishes (Nexus ack when `:nexus` is on; also set after nitrate-only when `:admin-console` is on without Nexus — see Archival). - Indexes: PK on `(id)`; partial `created_at WHERE archived_at IS NULL` serving the archive scan; partial `archived_at WHERE archived_at IS NOT NULL` serving the GC; `(source, created_at)` serving the telemetry scan. Each consumer has its own index, so a slow consumer never blocks the others. ## Backend producers (`app.loggers.audit`) @@ -42,11 +42,15 @@ Penpot records what users do as events in the Postgres `audit_log` table. There - The in-browser collector (`app.main.data.event`) only starts after `get-enabled-flags` confirms the backend wants events. It turns Potok events and explicit `ev/event` calls (nitrate membership changes, workspace file stats, crash reports) into a capped buffer (1024, chunks of 100, 2s debounce, current profile only) and sends fire-and-forget. `skip-audit?` exists for resumed dashboard actions so one user gesture is not counted twice. - Because collection is best-effort and includes `PerformanceObserver` noise (`performance-*` triggers), backend tests must never assert exact frontend event counts. -## Archival to Nexus and retention +## Archival to Nexus / Nitrate and retention -- Long-term storage lives outside Penpot in Nexus. Every 5m the `:audit-log-archive` cron takes chunks of 128 unarchived rows (`FOR UPDATE SKIP LOCKED`), POSTs them as transit `{:events [...]}` authenticated with `x-shared-key: "nexus "` (`:nexus-shared-key`, else derived from the instance secret), and marks `archived_at=now()` only on HTTP 204, in the same transaction. Anything else is retried on the next run; a missing URI with the flag on raises `:task-not-configured`. -- Every 5m the `:audit-log-gc` cron deletes all archived rows (no age filter), so archive must run before GC or data ships never. Cron dedup is best-effort (`mem:prod-infra/core`): two backends can fire the archiver twice, which is why the Nexus endpoint must be idempotent and the DB only marks acknowledged rows. -- Flags live in `common/flags.cljc` varia and are enabled as `PENPOT_FLAGS=enable-`: `:audit-log`, `:audit-log-archive`, `:audit-log-gc`, `:audit-log-logger` (structured `app.audit` log). `:telemetry-enabled` config auto-adds `:enable-telemetry`. +- Every 5m the `:audit-log-archive` **task** runs when **`:nexus` OR `:admin-console`** is on. It takes chunks of 128 unarchived rows (`FOR UPDATE SKIP LOCKED`). +- **Nitrate (Admin Console):** when `:admin-console` is on, allowlisted event names are POSTed via `nitrate/call :ingest-audit-log` (JSON batch `{:events [...]}`). The allowlist only shapes the HTTP body. +- **Nexus:** when `:nexus` is on, the full chunk is POSTed as transit `{:events [...]}` with `x-shared-key: "nexus "`. Missing URI with that flag raises `:task-not-configured`. +- **Both flags:** nitrate first, then Nexus on the same chunk. +- **`archived_at`:** set for **every row in the chunk** after success. With Nexus off, Nexus is treated as skipped success so nitrate-only still marks (including non-allowlisted names that were never POSTed). With Nexus on, mark only on Nexus HTTP 204; nitrate/Nexus failures leave rows unarchived for retry (Nitrate ingest must be idempotent). +- Every 5m the **`:audit-log-gc`** cron runs when **`:audit-log-gc` OR `:nexus` OR `:admin-console`**, and deletes all rows with `archived_at IS NOT NULL` (no age filter). Cron dedup is best-effort (`mem:prod-infra/core`). +- Flags: `:audit-log`, `:nexus`, `:audit-log-gc`, `:audit-log-logger`, `:admin-console`. Enabled as `PENPOT_FLAGS=enable-`. `:telemetry-enabled` config auto-adds `:enable-telemetry`. ## Tests diff --git a/backend/scripts/_env b/backend/scripts/_env index 0544911a02..4f7c5655bf 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -67,7 +67,7 @@ export PENPOT_FLAGS="\ enable-subscriptions"; # Uncomment for nexus integration testing -# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-nexus"; # export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; # Default deletion delay for devenv diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 1c5f953d44..b5190cff2b 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -13,6 +13,7 @@ [app.config :as cf] [app.db :as db] [app.http.client :as http] + [app.nitrate :as nitrate] [app.setup :as-alias setup] [integrant.core :as ig] [promesa.exec :as px])) @@ -69,15 +70,61 @@ :resp-body (:body resp)) false)))) +(def ^:private event-names-for-nitrate + #{"accept-organization-invitation" + "accept-team-invitation" + "accept-team-invitation-from" + "add-member-to-organization" + "add-team-to-organization" + "cancel-organization-invitation" + "change-organization-advanced-permission" + "create-file" + "create-organization" + "create-organization-invitation" + "create-project" + "create-team" + "create-team-access-request" + "create-team-invitation" + "create-team-invitations" + "create-webhook" + "delete-font" + "delete-organization" + "delete-project" + "delete-team" + "delete-team-invitation" + "delete-team-member" + "leave-team" + "move-project" + "move-team-to-organization" + "organization-sso-auth-failed" + "organization-sso-auth-started" + "organization-sso-auth-succeeded" + "permanently-delete-team-files" + "remove-organization-team" + "remove-team-from-organization" + "rename-organization" + "rename-project" + "restore-deleted-team-files" + "update-organization-invitation" + "update-organization-permissions" + "update-team-invitation" + "update-team-invitation-role" + "update-team-member-role" + "update-team-photo" + "verify-token"}) + +(defn- send-to-nitrate! + [cfg rows] + (when-let [events (->> rows + (filterv #(contains? event-names-for-nitrate (:name %))) + (not-empty))] + (nitrate/call cfg :ingest-audit-log {:events events}))) + (defn- mark-archived! [{:keys [::db/conn]} rows] (let [ids (db/create-array conn "uuid" (map :id rows))] (db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)" ids]))) -(def ^:private xf:create-event - (comp (map decode-row) - (map row->event))) - (def ^:private sql:get-audit-log-chunk "SELECT * FROM audit_log @@ -96,17 +143,30 @@ [{:keys [::uri] :as cfg}] (db/tx-run! cfg (fn [cfg] (when-let [rows (get-event-rows cfg)] - (let [events (into [] xf:create-event rows)] + (let [decoded (mapv decode-row rows) + events (mapv row->event decoded)] (l/trc :hint "archive events chunk" :uri uri :events (count events)) - (when (send! cfg events) - (mark-archived! cfg rows) - (count events))))))) + + (when (contains? cf/flags :admin-console) + (send-to-nitrate! cfg decoded)) + + ;; When :nexus is off, treat Nexus as skipped + ;; success so nitrate-only still marks the chunk. + ;; REPL :enabled true with neither send flag also marks + ;; with no outbound send — always set at least one flag. + (let [nexus-ok? (if (contains? cf/flags :nexus) + (send! cfg events) + true)] + (when nexus-ok? + (mark-archived! cfg rows) + (count events)))))))) (def ^:private schema:handler-params [:map ::db/pool ::setup/shared-keys - ::http/client]) + ::http/client + [:app.nitrate/client {:optional true} [:maybe :map]]]) (defmethod ig/assert-key ::handler [_ params] @@ -114,17 +174,23 @@ (defmethod ig/init-key ::handler [_ cfg] - (fn [params] + (fn [{:keys [props] :as params}] ;; NOTE: this let allows overwrite default configured values from - ;; the repl, when manually invoking the task. - (let [enabled (or (contains? cf/flags :audit-log-archive) - (:enabled params false)) + ;; the repl, when manually invoking the task. Prefer setting a send + ;; flag (:admin-console and/or :nexus); :enabled alone + ;; marks chunks without shipping. `invoke!` passes params under + ;; `:props`; direct REPL calls may pass a flat map. + (let [props (or props params) + enabled (or (contains? cf/flags :nexus) + (contains? cf/flags :admin-console) + (:enabled props false)) uri (cf/get :audit-log-archive-uri) - uri (or uri (:uri params)) + uri (or uri (:uri props)) cfg (assoc cfg ::uri uri)] - (when (and enabled (not uri)) + (when (and (contains? cf/flags :nexus) + (not uri)) (ex/raise :type :internal :code :task-not-configured :hint "archive task not configured, missing uri")) @@ -138,4 +204,3 @@ (when (pos? total) (l/dbg :hint "events archived" :total total)))))))) - diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index de72b9a0ef..c60f1362ed 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -575,7 +575,8 @@ :app.loggers.audit.archive-task/handler {::setup/shared-keys (ig/ref ::setup/shared-keys) ::http.client/client (ig/ref ::http.client/client) - ::db/pool (ig/ref ::db/pool)} + ::db/pool (ig/ref ::db/pool) + :app.nitrate/client (ig/ref :app.nitrate/client)} :app.loggers.audit.gc-task/handler {::db/pool (ig/ref ::db/pool)} @@ -654,11 +655,13 @@ {:cron #penpot/cron "0 30 */3,23 * * ?" :task :telemetry} - (when (contains? cf/flags :audit-log-archive) + (when (or (contains? cf/flags :nexus) + (contains? cf/flags :admin-console)) {:cron #penpot/cron "0 */5 * * * ?" ;; every 5m :task :audit-log-archive}) - (when (contains? cf/flags :audit-log-gc) + (when (or (contains? cf/flags :nexus) + (contains? cf/flags :admin-console)) {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m :task :audit-log-gc})]} diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3b4b41eff0..a759e63c57 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -7,6 +7,7 @@ (ns app.nitrate "Module that make calls to the external nitrate aplication" (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.json :as json] @@ -496,6 +497,29 @@ schema:redeem-result (assoc params :throw-on-error? true))) +(def ^:private audit-event-keys + [:id :name :type :profile-id :props :context + :created-at :tracked-at :source :ip-addr]) + +(defn- ->audit-event + [event] + (-> (select-keys event audit-event-keys) + (d/without-nils))) + +(defn- ingest-audit-log-api + "POST a batch of audit events to Admin Console `/api/audit-log`. + Expects HTTP 204; returns nil on success. 4xx raises when + `:throw-on-error?` is set." + [cfg {:keys [events] :as params}] + (let [request-params {:events (mapv ->audit-event events)} + params (assoc params + :request-params request-params + :throw-on-error? true)] + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/audit-log") + nil + params))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -505,28 +529,29 @@ (when (contains? cf/flags :admin-console) (when (nil? (cf/get :admin-console-uri)) (l/warn :hint "admin console is not configured; nitrate calls will fail until the setup is complete")) - {:get-team-organization (partial get-team-organization-api cfg) - :get-teams-organizations (partial get-teams-organizations-api cfg) - :set-team-organization (partial set-team-organization-api cfg) - :get-organization-membership (partial get-organization-membership-api cfg) - :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) - :get-organization-summary (partial get-organization-summary-api cfg) - :get-owned-organizations (partial get-owned-organizations-api cfg) - :get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg) - :get-organization-members (partial get-organization-members-api cfg) - :cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg) - :add-profile-to-organization (partial add-profile-to-organization-api cfg) - :remove-profile-from-organization (partial remove-profile-from-organization-api cfg) - :get-organization-permissions (partial get-organization-permissions-api cfg) - :get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg) - :get-organization-sso (partial get-organization-sso-api cfg) - :delete-team (partial delete-team-api cfg) - :remove-team-from-organization (partial remove-team-from-organization-api cfg) - :get-subscription (partial get-subscription-api cfg) - :get-subscription-warning (partial get-subscription-warning-api cfg) - :connectivity (partial get-connectivity-api cfg) - :get-identity (partial get-identity-api cfg) - :redeem-activation-code (partial redeem-activation-code-api cfg)})) + {:get-team-organization (partial get-team-organization-api cfg) + :get-teams-organizations (partial get-teams-organizations-api cfg) + :set-team-organization (partial set-team-organization-api cfg) + :get-organization-membership (partial get-organization-membership-api cfg) + :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) + :get-organization-summary (partial get-organization-summary-api cfg) + :get-owned-organizations (partial get-owned-organizations-api cfg) + :get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg) + :get-organization-members (partial get-organization-members-api cfg) + :cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg) + :add-profile-to-organization (partial add-profile-to-organization-api cfg) + :remove-profile-from-organization (partial remove-profile-from-organization-api cfg) + :get-organization-permissions (partial get-organization-permissions-api cfg) + :get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg) + :get-organization-sso (partial get-organization-sso-api cfg) + :delete-team (partial delete-team-api cfg) + :remove-team-from-organization (partial remove-team-from-organization-api cfg) + :get-subscription (partial get-subscription-api cfg) + :get-subscription-warning (partial get-subscription-warning-api cfg) + :connectivity (partial get-connectivity-api cfg) + :get-identity (partial get-identity-api cfg) + :redeem-activation-code (partial redeem-activation-code-api cfg) + :ingest-audit-log (partial ingest-audit-log-api cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj new file mode 100644 index 0000000000..f8011c8b34 --- /dev/null +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -0,0 +1,227 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.loggers-audit-archive-task-test + (:require + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.client :as-alias http] + [app.setup :as-alias setup] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [integrant.core :as ig] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private archive-uri "http://nexus.example/archive") + +(defn- insert-audit-row! + [profile-id name] + (th/db-insert! :audit-log + {:id (uuid/next) + :name name + :type "action" + :source "backend" + :profile-id profile-id + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson {:team-id (uuid/next)}) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)})) + +(t/deftest archive-events-sends-to-nitrate-then-nexus + ;; Create profile before enabling :admin-console — system nitrate + ;; client is nil in tests, and team bootstrap would call it. + (let [prof (th/create-profile* 1 {:is-active true}) + order (atom [])] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call + :return (fn [& _] + (swap! order conj :nitrate) + nil)} + http-mock {:target 'app.http.client/req + :return (fn [& _] + (swap! order conj :nexus) + {:status 204})}] + (insert-audit-row! (:id prof) "create-project") + (insert-audit-row! (:id prof) "create-file") + (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (:called? @nitrate-mock)) + (t/is (:called? @http-mock)) + (t/is (= 1 (:call-count @nitrate-mock))) + (t/is (= 1 (:call-count @http-mock))) + (t/is (= [:nitrate :nexus] @order)) + (let [[_ method params] (:call-args @nitrate-mock)] + (t/is (= :ingest-audit-log method)) + (t/is (= 2 (count (:events params)))) + (t/is (= #{"create-project" "create-file"} + (into #{} (map :name) (:events params)))) + (t/is (every? uuid? (map :id (:events params))))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 2 (count rows)))))))) + +(t/deftest archive-events-filters-nitrate-event-names + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "create-project") + (insert-audit-row! (:id prof) "unrelated-event") + (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (:called? @nitrate-mock)) + (t/is (:called? @http-mock)) + (let [[_ _ params] (:call-args @nitrate-mock)] + (t/is (= ["create-project"] (mapv :name (:events params))))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 2 (count rows)))))))) + +(t/deftest archive-events-skips-nitrate-when-no-matching-names + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "unrelated-event") + (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (false? (:called? @nitrate-mock))) + (t/is (:called? @http-mock)) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-admin-console-only-marks-without-uri + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-archive {}) + (t/is (:called? @nitrate-mock)) + (t/is (false? (:called? @http-mock))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-admin-console-only-marks-non-allowlisted + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "unrelated-event") + (th/run-task! :audit-log-archive {}) + (t/is (false? (:called? @nitrate-mock))) + (t/is (false? (:called? @http-mock))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-propagates-nitrate-error + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call + :throw (ex-info "nitrate down" {})} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "create-project") + (t/is (thrown? clojure.lang.ExceptionInfo + (th/run-task! :audit-log-archive {:uri archive-uri}))) + (t/is (:called? @nitrate-mock)) + (t/is (false? (:called? @http-mock))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-uses-nexus-without-admin-console + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} + nitrate-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (false? (:called? @nitrate-mock))) + (t/is (:called? @http-mock)) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-skips-mark-when-nexus-fails + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 500}}] + (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (:called? @http-mock)) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-both-flags-skips-mark-when-nexus-fails + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 500}}] + (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (:called? @nitrate-mock)) + (t/is (:called? @http-mock)) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-encodes-db-rows-for-nitrate + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console} + cf/config (assoc cf/config + :admin-console-uri "http://ac.example/admin-console/" + :admin-console-shared-key "ac-key" + :nexus-shared-key "nexus-key")] + (let [nitrate-bodies (atom []) + shared-keys {:admin-console "ac-key" :nexus "nexus-key"} + client (ig/init-key :app.nitrate/client + {::http/client (Object.) + ::setup/shared-keys shared-keys}) + handler (ig/init-key :app.loggers.audit.archive-task/handler + {::db/pool (:app.db/pool th/*system*) + ::setup/shared-keys shared-keys + ::http/client (:app.http.client/client th/*system*) + :app.nitrate/client client})] + (with-mocks [http-mock {:target 'app.http.client/req + :return + (fn [_ req & _] + (when (str/includes? (str (:uri req)) "api/audit-log") + (swap! nitrate-bodies conj (:body req))) + {:status 204})}] + (let [team-id (uuid/next) + row (th/db-insert! :audit-log + {:id (uuid/next) + :name "create-project" + :type "action" + :source "backend" + :profile-id (:id prof) + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson {:team-id team-id}) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)})] + (handler {:props {:uri archive-uri}}) + (t/is (= 1 (count @nitrate-bodies))) + (let [body (json/decode (first @nitrate-bodies) :key-fn json/read-kebab-key) + event (first (:events body))] + (t/is (= 1 (count (:events body)))) + (t/is (= (str (:id row)) (str (:id event)))) + (t/is (= "create-project" (:name event))) + (t/is (= "127.0.0.1" (:ip-addr event))) + (t/is (string? (:created-at event))) + (t/is (string? (:tracked-at event))) + (t/is (= (str team-id) (str (get-in event [:props :team-id]))))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))))) + +(t/deftest archive-events-requires-uri-when-nexus-flag-set + (with-redefs [cf/flags #{:nexus} + cf/config (dissoc cf/config :audit-log-archive-uri)] + (let [data (ex-data (try + (th/run-task! :audit-log-archive {}) + (catch Throwable cause + cause)))] + (t/is (= :task-not-configured (:code data)))))) diff --git a/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj b/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj new file mode 100644 index 0000000000..b8a679d842 --- /dev/null +++ b/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj @@ -0,0 +1,81 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.nitrate-ingest-audit-log-test + (:require + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.http.client :as-alias http] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [app.setup :as-alias setup] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [integrant.core :as ig] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest ingest-audit-log-posts-events-batch + (binding [cf/flags (conj cf/flags :admin-console) + cf/config (assoc cf/config :admin-console-uri "http://ac.example/admin-console/")] + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 204}}] + (let [client (ig/init-key :app.nitrate/client + {::http/client (Object.) + ::setup/shared-keys {:admin-console "test-shared-key"}}) + cfg {:app.nitrate/client client} + p1 (uuid/next) + p2 (uuid/next) + e1 (uuid/next) + e2 (uuid/next) + team-id (uuid/next) + now (ct/now)] + (nitrate/call cfg :ingest-audit-log + {::rpc/profile-id p1 + :events [{:id e1 + :name "create-project" + :type "action" + :profile-id p1 + :props {:team-id team-id} + :context {} + :created-at now + :tracked-at now + :source "backend" + :ip-addr "127.0.0.1"} + {:id e2 + :name "create-file" + :type "action" + :profile-id p2 + :props {} + :context {} + :created-at now + :tracked-at now + :source "backend" + :ip-addr "10.0.0.1"}]}) + (t/is (= 1 (:call-count @http-mock))) + (let [[_ req] (:call-args @http-mock) + body (json/decode (:body req) :key-fn json/read-kebab-key) + e1' (first (:events body)) + e2' (second (:events body))] + (t/is (= :post (:method req))) + (t/is (str/ends-with? (str (:uri req)) "api/audit-log")) + (t/is (= 2 (count (:events body)))) + (t/is (= "create-project" (:name e1'))) + (t/is (= "create-file" (:name e2'))) + (t/is (= (str e1) (str (:id e1')))) + (t/is (= (str e2) (str (:id e2')))) + (t/is (= (str p1) (str (:profile-id e1')))) + (t/is (= (str p2) (str (:profile-id e2')))) + (t/is (= "127.0.0.1" (:ip-addr e1'))) + (t/is (= "10.0.0.1" (:ip-addr e2'))) + (t/is (string? (:created-at e1'))) + (t/is (string? (:tracked-at e1'))) + (t/is (= (str now) (:created-at e1'))) + (t/is (= (str team-id) (str (get-in e1' [:props :team-id]))))))))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 60353f730f..7589a23668 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -60,8 +60,8 @@ (def varia "Rest of the flags" #{:audit-log - :audit-log-archive - :audit-log-gc + ;; Enables shipping audit_log chunks to Nexus. + :nexus :audit-log-logger :auto-file-snapshot ;; enables the `/api/doc` endpoint that lists all the rpc methods available. From 4a5da94db2ba0e40a8f2806484983c63a02722a9 Mon Sep 17 00:00:00 2001 From: Nicola Bortoletto Date: Thu, 1 Oct 2026 08:24:21 +0200 Subject: [PATCH 012/325] :globe_with_meridians: Add translations for: Italian Currently translated at 82.9% (1888 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/ --- frontend/translations/it.po | 109 ++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/frontend/translations/it.po b/frontend/translations/it.po index dbd2c5c288..3f365ec4cb 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-28 08:14+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: Italian " -"\n" +"PO-Revision-Date: 2026-10-02 05:51+0000\n" +"Last-Translator: Nicola Bortoletto \n" +"Language-Team: Italian \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 2026.10.dev0\n" +"X-Generator: Weblate 2026.10\n" #: src/app/main/ui/auth/register.cljs:227, src/app/main/ui/static.cljs:191, src/app/main/ui/viewer/login.cljs:119 msgid "auth.already-have-account" @@ -7876,3 +7876,102 @@ msgstr "Le versioni salvate automaticamente saranno conservate per %s giorni." #~ msgid "onboarding.slide.1.desc1" #~ msgstr "Crea interazioni complete per imitare al meglio il prodotto finale." + +#: src/app/main/ui/auth/recovery_request.cljs:111, src/app/main/ui/auth/register.cljs:250 +msgid "auth.check-email" +msgstr "Controlla la tua email!" + +#: src/app/main/ui/auth/register.cljs:251 +msgid "auth.verification-sent-email" +msgstr "Abbiamo inviato un'email di verifica a:" + +#: src/app/main/data/nitrate.cljs:599 +msgid "dashboard.change-organization-modal.description" +msgstr "" +"I progetti e i file rimarranno disponibili ai membri del team. Il team " +"otterrà la configurazione dalla nuova organizzazione." + +#: src/app/main/ui/dashboard/check_updates.cljs:334 +msgid "dashboard.check-updates.available-message" +msgstr "" +"Le nuove versioni includono miglioramenti alle prestazioni, nuove " +"funzionalità e correzioni che mantengono la tua installazione attuale." + +#: src/app/main/ui/dashboard/check_updates.cljs:309 +msgid "dashboard.check-updates.available-title" +msgstr "È DISPONIBILE UNA NUOVA VERSIONE DI PENPOT" + +#: src/app/main/ui/dashboard/check_updates.cljs:341 +msgid "dashboard.check-updates.highlights-title" +msgstr "Novità - In evidenza" + +#: src/app/main/ui/dashboard/check_updates.cljs:318 +msgid "dashboard.check-updates.installed-version" +msgstr "Versione installata" + +#: src/app/main/ui/dashboard/check_updates.cljs:326 +msgid "dashboard.check-updates.latest-version" +msgstr "Ultima versione" + +#: src/app/main/ui/dashboard/check_updates.cljs:235 +msgid "dashboard.check-updates.try-again" +msgstr "Riprova" + +#: src/app/main/ui/dashboard/check_updates.cljs:230 +msgid "dashboard.check-updates.unable-hint" +msgstr "" +"Controlla la tua connessione o la configurazione dell'istanza e riprova." + +#: src/app/main/ui/dashboard/check_updates.cljs:215 +msgid "dashboard.check-updates.unable-title" +msgstr "IMPOSSIBILE CONTROLLARE GLI AGGIORNAMENTI" + +#: src/app/main/ui/dashboard/check_updates.cljs:265 +msgid "dashboard.check-updates.uptodate-message" +msgstr "Stai eseguendo l'ultima versione stabile:" + +#: src/app/main/ui/dashboard/check_updates.cljs:254 +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT È AGGIORNATO" + +#: src/app/main/ui/dashboard/check_updates.cljs:371 +msgid "dashboard.check-updates.view-changelog" +msgstr "Visualizza changelog completo" + +#: src/app/main/ui/dashboard/check_updates.cljs:374 +msgid "dashboard.check-updates.view-release-notes" +msgstr "Visualizza note di rilascio" + +#: src/app/main/ui/dashboard/layout_toggle.cljs:32 +msgid "dashboard.files-layout.grid" +msgstr "Visualizzazione a griglia" + +#: src/app/main/ui/dashboard/layout_toggle.cljs:28 +msgid "dashboard.files-layout.list" +msgstr "Visualizzazione a elenco" + +#: src/app/main/ui/dashboard/grid.cljs:252 +msgid "dashboard.grid.last-modified-at" +msgstr "Ultima modifica %s" + +#: src/app/main/ui/dashboard/import.cljs:597 +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 libreria è stata collegata automaticamente tramite nome." +msgstr[1] "%s librerie sono state collegate automaticamente tramite nome." + +#: src/app/main/ui/dashboard/import.cljs:733 +msgid "dashboard.import.confirm-library-links" +msgstr "Conferma collegamenti libreria" + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "Connetti le librerie selezionate" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Nome file" + +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "Prossimo file" From a3ab8bf6fd7437f7c3c48a63c8b79603716a641c Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Thu, 1 Oct 2026 15:40:40 +0200 Subject: [PATCH 013/325] :globe_with_meridians: Add translations for: Dutch Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/ --- frontend/translations/nl.po | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 5c7ffa4b37..4a9a5210fb 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-30 16:51+0000\n" +"PO-Revision-Date: 2026-10-02 05:51+0000\n" "Last-Translator: Stephan Paternotte \n" "Language-Team: Dutch \n" @@ -9,7 +9,7 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 2026.10.dev0\n" +"X-Generator: Weblate 2026.10\n" #: src/app/main/ui/auth/register.cljs:227, src/app/main/ui/static.cljs:191, src/app/main/ui/viewer/login.cljs:119 msgid "auth.already-have-account" @@ -1738,9 +1738,9 @@ msgstr "De registratie is momenteel uitgeschakeld." #: src/app/main/errors.cljs:538 msgid "errors.save-failed" msgstr "" -"Jouw laatste wijzigingen zijn niet bevestigd als opgeslagen. Het opslaan " -"kan niet automatisch worden hervat. Sluit of herlaad dit tabblad niet: je " -"kunt wijzigingen verliezen. Neem contact op met ondersteuning voor hulp." +"Autom. opslaan werkt niet vanwege een fout. Neem contact op met de " +"ondersteuning om de fout te melden en laad de pagina opnieuw om verder te " +"gaan vanaf uw laatst opgeslagen versie." #: src/app/main/errors.cljs:764 msgid "errors.svg-parser.invalid-svg" From 09ffcb1fbcaf40102e3959ab230751cbb5d96382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 10:24:05 +0200 Subject: [PATCH 014/325] :zap: Reduce render-wasm GPU memory and move cost on large pages (#11980) --- .../app/main/data/workspace/modifiers.cljs | 32 +++--- .../src/app/main/data/workspace/shapes.cljs | 8 +- .../frontend_tests/logic/bool_move_test.cljs | 99 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + render-wasm/src/render.rs | 32 +++--- render-wasm/src/render/gpu_state.rs | 86 +++------------- render-wasm/src/render/surfaces.rs | 55 ++++------- render-wasm/src/state.rs | 6 +- 8 files changed, 173 insertions(+), 147 deletions(-) create mode 100644 frontend/test/frontend_tests/logic/bool_move_test.cljs diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 2cee1752a2..328b366bfb 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -21,7 +21,6 @@ [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] - [app.common.types.path :as path] [app.common.types.shape-tree :as ctst] [app.common.types.shape.attrs :refer [editable-attrs]] [app.common.types.shape.layout :as ctl] @@ -885,6 +884,18 @@ ids (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + options + (cond-> options + translation? + (assoc :resize-ids + (into [] + (remove (fn [id] + (let [parent-id (dm/get-in objects [id :parent-id])] + (and (contains? ids parent-id) + (= (get transforms id) + (get transforms parent-id)))))) + ids))) + update-shape (fn [shape] (let [shape-id (dm/get-prop shape :id) @@ -894,14 +905,6 @@ (gsh/apply-transform transform) (ctm/apply-structure-modifiers modifiers)))) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) - undo-id (js/Symbol)] (rx/concat @@ -912,16 +915,7 @@ (clear-local-transform) (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) - - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info. - ;; `update-layout? false`: recalculating a bool path can never change - ;; `:hidden`, and the layout check would recompute the whole boolean - ;; path in WASM once per bool shape just to find that out. - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options - :with-objects? true - :update-layout? false))) + (dwsh/update-shapes ids update-shape options)) (if undo-transation? (rx/of (dwu/commit-undo-transaction undo-id)) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 447a192a7d..0b195be68b 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -110,7 +110,7 @@ ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-touched undo-group with-objects? changed-sub-attr changed-item-index - translation? skip-grid-reassignment? skip-component-sync?] + translation? skip-grid-reassignment? skip-component-sync? resize-ids] :or {reg-objects? false save-undo? true stack-undo? false @@ -153,7 +153,7 @@ :ignore-touched ignore-touched :with-objects? with-objects? :skip-grid-reassignment? skip-grid-reassignment?}) - (cond-> reg-objects? (pcb/resize-parents ids)) + (cond-> reg-objects? (pcb/resize-parents (or resize-ids ids))) (pcb/set-translation? translation?) (pcb/set-skip-component-sync? skip-component-sync?))))] ;; Check buffered text candidates when the buffer is committed. @@ -191,7 +191,7 @@ {:as props :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-touched undo-group with-objects? changed-sub-attr changed-item-index translation? - skip-grid-reassignment? skip-component-sync?] + skip-grid-reassignment? skip-component-sync? resize-ids] :or {reg-objects? false save-undo? true stack-undo? false @@ -250,7 +250,7 @@ (rx/empty)) (if (seq (:redo-changes changes)) - (let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))] + (let [changes (cond-> changes reg-objects? (pcb/resize-parents (or resize-ids ids)))] (rx/of (dch/commit-changes changes))) (rx/empty)) diff --git a/frontend/test/frontend_tests/logic/bool_move_test.cljs b/frontend/test/frontend_tests/logic/bool_move_test.cljs new file mode 100644 index 0000000000..5191d4e98e --- /dev/null +++ b/frontend/test/frontend_tests/logic/bool_move_test.cljs @@ -0,0 +1,99 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.logic.bool-move-test + "Moving shapes must only recalculate booleans whose children moved + relative to them, and leave every boolean equal to a fresh calculation." + (:require + [app.common.geom.point :as gpt] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] + [app.common.types.pages-list :as ctpl] + [app.common.types.path :as path] + [app.common.types.shape-tree :as ctst] + [app.main.data.workspace.modifiers :as dwm] + [app.render-wasm.api :as wasm.api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private bool-calculations (atom 0)) + +(defn- counting-calc-bool-content + [shape objects] + (swap! bool-calculations inc) + (path/calc-bool-content shape objects)) + +(def ^:private original-calculate-bool wasm.api/calculate-bool) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (thw/setup-wasm-mocks!) + (reset! bool-calculations 0) + (set! wasm.api/calculate-bool counting-calc-bool-content) + (set! path/wasm:calc-bool-content counting-calc-bool-content)) + :after (fn [] + (set! wasm.api/calculate-bool original-calculate-bool) + (set! path/wasm:calc-bool-content nil) + (thw/teardown-wasm-mocks!))}) + +(defn- file-with-bool-in-board + [] + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :board :x 0 :y 0 :width 400 :height 400) + (cths/add-sample-shape :bool {:type :bool + :bool-type :union + :parent-label :board}) + (ctho/add-rect :rect1 :x 10 :y 10 :width 100 :height 100 :parent-label :bool) + (ctho/add-rect :rect2 :x 60 :y 60 :width 100 :height 100 :parent-label :bool)) + page (cthf/current-page file) + bool (path/update-bool-shape (cths/get-shape file :bool) (:objects page))] + (update file :data ctpl/update-page (:id page) #(ctst/set-shape % bool)))) + +(defn- disable-pixel-grid + [] + (fn [state] + (update state :workspace-layout disj :snap-pixel-grid))) + +(defn- run-move + [done labels delta expected-calculations & {:keys [pixel-grid?] :or {pixel-grid? true}}] + (let [file (file-with-bool-in-board) + store (ths/setup-store file) + ids (map #(:id (cths/get-shape file %)) labels) + modif-tree (dwm/create-modif-tree ids (ctm/move-modifiers delta)) + events (cond->> [(dwm/apply-wasm-modifiers modif-tree)] + (not pixel-grid?) (cons (disable-pixel-grid)))] + (reset! bool-calculations 0) + (ths/run-store + store done events + (fn [new-state] + (let [calculations @bool-calculations + file' (ths/get-file-from-state new-state) + objects' (:objects (cthf/current-page file')) + after (cths/get-shape file' :bool) + fresh (path/update-bool-shape after objects')] + (t/is (= expected-calculations calculations)) + (t/is (= (:selrect fresh) (:selrect after))) + (t/is (= (vec (:content fresh)) (vec (:content after))))))))) + +(t/deftest moving-a-board-does-not-recalculate-the-booleans-inside + (t/async + done + (run-move done [:board :bool :rect1 :rect2] (gpt/point 10 20) 0))) + +(t/deftest moving-a-boolean-child-recalculates-the-boolean + (t/async + done + (run-move done [:rect1] (gpt/point 10 20) 1))) + +(t/deftest moving-a-board-without-pixel-grid-does-not-recalculate-its-booleans + (t/async + done + (run-move done [:board] (gpt/point 10.5 20.25) 0 :pixel-grid? false))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 1f78325a70..07f4cb0e8a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -39,6 +39,7 @@ [frontend-tests.errors-test] [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] + [frontend-tests.logic.bool-move-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] [frontend-tests.logic.copy-paste-typography-test] @@ -174,6 +175,7 @@ 'frontend-tests.errors-test 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test + 'frontend-tests.logic.bool-move-test 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens 'frontend-tests.logic.copy-paste-typography-test diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b840563f4f..26a99a499e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1202,7 +1202,9 @@ impl RenderState { // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), // while still preventing stale pixels from surviving across full-quality renders. if !self.cache_cleared_this_render { - self.surfaces.clear_cache(self.background_color); + if self.options.is_debug_visible() { + self.surfaces.clear_cache(self.background_color); + } self.cache_cleared_this_render = true; } let tile_rect = self.get_current_aligned_tile_bounds()?; @@ -1220,7 +1222,7 @@ impl RenderState { &self.tile_viewbox, ¤t_tile, &tile_rect, - false, + !self.options.is_debug_visible(), self.render_area, self.get_scale(), self.viewbox.area, @@ -2333,10 +2335,7 @@ impl RenderState { .is_none_or(|s| s.width() < win_w || s.height() < win_h); if needs_alloc { scratch_surface = get_gpu_state() - .create_surface_with_isize( - "drag_crop_scratch".to_string(), - skia::ISize::new(win_w, win_h), - ) + .create_surface_with_isize(skia::ISize::new(win_w, win_h)) .ok(); } let Some(scratch) = scratch_surface.as_mut() else { @@ -2512,11 +2511,13 @@ impl RenderState { s.canvas().scale((scale, scale)); }); - self.surfaces.resize_cache_from_viewbox( - &self.viewbox, - &self.cached_viewbox, - self.options.dpr_viewport_interest_area_threshold, - )?; + if self.options.is_debug_visible() { + self.surfaces.resize_cache_from_viewbox( + &self.viewbox, + &self.cached_viewbox, + self.options.dpr_viewport_interest_area_threshold, + )?; + } // FIXME - review debug // debug::render_debug_tiles_for_viewbox(self); @@ -4319,11 +4320,16 @@ impl RenderState { if !is_empty || self.current_tile_had_shapes { if self.options.is_interactive_transform() { // During drag, avoid snapshot-based caching. Draw Current directly - // into Target (and Cache) to reduce stalls. + // into Target (and Cache, for the debug views) to reduce stalls. + let draw_on_cache = if self.options.is_debug_visible() { + surfaces::DrawOnCache::Yes + } else { + surfaces::DrawOnCache::No + }; self.surfaces.draw_current_tile_into_backbuffer( &tile_rect, self.background_color, - surfaces::DrawOnCache::Yes, + draw_on_cache, ); } else { self.apply_render_to_final_canvas()?; diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 7a158b5fc6..1c0bdcf314 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -88,93 +88,35 @@ impl GpuState { None } - fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { - unsafe { - gl::DeleteTextures(1, &texture_id); - gl::GetError() == 0 - } - } - - fn create_gl_texture(&mut self, width: i32, height: i32) -> gl::types::GLuint { - let mut texture_id: gl::types::GLuint = 0; - - unsafe { - gl::GenTextures(1, &mut texture_id); - gl::BindTexture(gl::TEXTURE_2D, texture_id); - - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE as i32); - - gl::TexImage2D( - gl::TEXTURE_2D, - 0, - gl::RGBA8 as i32, - width, - height, - 0, - gl::RGBA, - gl::UNSIGNED_BYTE, - std::ptr::null(), - ); - } - - texture_id - } - - pub fn delete_surface(&mut self, surface: &mut skia::Surface) -> bool { - let Some(texture) = skia::gpu::surfaces::get_backend_texture( - surface, - skia_safe::surface::BackendHandleAccess::FlushRead, - ) else { - return false; - }; - let Some(texture_info) = gpu::backend_textures::get_gl_texture_info(&texture) else { - return false; - }; - self.delete_gl_texture(texture_info.id) - } - - pub fn create_surface_with_isize( - &mut self, - label: String, - size: ISize, - ) -> Result { - self.create_surface_with_dimensions(label, size.width, size.height) + pub fn create_surface_with_isize(&mut self, size: ISize) -> Result { + self.create_surface_with_dimensions(size.width, size.height) } pub fn create_surface_with_dimensions( &mut self, - label: String, width: i32, height: i32, ) -> Result { - let backend_texture = unsafe { - let texture_id = self.create_gl_texture(width, height); - let texture_info = TextureInfo { - target: gl::TEXTURE_2D, - id: texture_id, - format: gl::RGBA8, - protected: skia::gpu::Protected::No, - }; - gpu::backend_textures::make_gl((width, height), gpu::Mipmapped::No, texture_info, label) - }; + let image_info = skia::ImageInfo::new( + (width, height), + skia::ColorType::RGBA8888, + skia::AlphaType::Premul, + None, + ); - let surface = gpu::surfaces::wrap_backend_texture( + gpu::surfaces::render_target( &mut self.context, - &backend_texture, + gpu::Budgeted::No, + &image_info, + None, gpu::SurfaceOrigin::BottomLeft, None, - skia::ColorType::RGBA8888, - None, + false, None, ) .ok_or(Error::CriticalError( "Failed to create Skia surface".to_string(), - ))?; - - Ok(surface) + )) } /// Create a Skia surface that will be used for rendering. diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 761b2d02d6..be6ccb1fb5 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -165,8 +165,7 @@ impl DocAtlas { pub fn try_new() -> Result { // Keep atlas as a regular surface like the rest. Start with a tiny // transparent surface and grow it on demand. - let mut surface = - get_gpu_state().create_surface_with_dimensions("atlas".to_string(), 1, 1)?; + let mut surface = get_gpu_state().create_surface_with_dimensions(1, 1)?; surface.canvas().clear(skia::Color::TRANSPARENT); @@ -276,8 +275,7 @@ impl DocAtlas { return Ok(()); } - let mut new_surface = - gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; + let mut new_surface = gpu_state.create_surface_with_dimensions(new_w, new_h)?; new_surface.canvas().clear(skia::Color::TRANSPARENT); // Copy old atlas into the new one with offset. @@ -307,7 +305,6 @@ impl DocAtlas { self.origin = skia::Point::new(new_left, new_top); self.size = skia::ISize::new(new_w, new_h); self.scale = new_scale; - gpu_state.delete_surface(&mut self.surface); self.surface = new_surface; Ok(()) } @@ -528,35 +525,25 @@ impl Surfaces { let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); let target = gpu_state.create_target_surface(width, height)?; - let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; - let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; - let backbuffer = - gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; + let filter = gpu_state.create_surface_with_isize(extra_tile_dims)?; + let cache = gpu_state.create_surface_with_dimensions(width, height)?; + let backbuffer = gpu_state.create_surface_with_dimensions(width, height)?; let max_texture_size = gpu_state.max_texture_size(); - let tile_atlas = gpu_state.create_surface_with_dimensions( - "tile_atlas".to_string(), - max_texture_size, - max_texture_size, - )?; + let tile_atlas = + gpu_state.create_surface_with_dimensions(max_texture_size, max_texture_size)?; - let current = - gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; + let current = gpu_state.create_surface_with_isize(extra_tile_dims)?; - let drop_shadows = - gpu_state.create_surface_with_isize("drop_shadows".to_string(), extra_tile_dims)?; - let inner_shadows = - gpu_state.create_surface_with_isize("inner_shadows".to_string(), extra_tile_dims)?; - let text_drop_shadows = gpu_state - .create_surface_with_isize("text_drop_shadows".to_string(), extra_tile_dims)?; - let shape_fills = - gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?; - let shape_strokes = - gpu_state.create_surface_with_isize("shape_strokes".to_string(), extra_tile_dims)?; - let export = gpu_state.create_surface_with_isize("export".to_string(), extra_tile_dims)?; + let drop_shadows = gpu_state.create_surface_with_isize(extra_tile_dims)?; + let inner_shadows = gpu_state.create_surface_with_isize(extra_tile_dims)?; + let text_drop_shadows = gpu_state.create_surface_with_isize(extra_tile_dims)?; + let shape_fills = gpu_state.create_surface_with_isize(extra_tile_dims)?; + let shape_strokes = gpu_state.create_surface_with_isize(extra_tile_dims)?; + let export = gpu_state.create_surface_with_isize(extra_tile_dims)?; - let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; - let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; + let ui = gpu_state.create_surface_with_dimensions(width, height)?; + let debug = gpu_state.create_surface_with_dimensions(width, height)?; let tiles = TileTextureCache::new(tile_atlas.width(), tile_atlas.height()); let atlas = DocAtlas::try_new()?; @@ -677,12 +664,10 @@ impl Surfaces { )); canvas.scale((s / scale, s / scale)); - self.atlas.surface.draw( - canvas, - (0.0, 0.0), - self.sampling_options, - Some(&skia::Paint::default()), - ); + let sampling = skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::None); + self.atlas + .surface + .draw(canvas, (0.0, 0.0), sampling, Some(&skia::Paint::default())); canvas.restore(); } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 5b2684ecb4..42653f876a 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -50,18 +50,16 @@ impl State { .to_string(), )); } - self.saved_shapes = Some(self.shapes.clone()); - self.shapes = ShapesPool::new(); + self.saved_shapes = Some(std::mem::take(&mut self.shapes)); Ok(()) } // Disposes of the temporary shapes pool restoring the normal pool // Will panic if a there is no temporary pool. pub fn end_temp_objects(&mut self) -> Result<()> { - self.shapes = self.saved_shapes.clone().ok_or(Error::CriticalError( + self.shapes = self.saved_shapes.take().ok_or(Error::CriticalError( "Tried to end temp objects but not content to be restored is present".to_string(), ))?; - self.saved_shapes = None; Ok(()) } From 1554847d40aa1fb160eea3b0c9e6280991c96e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 10:51:21 +0200 Subject: [PATCH 015/325] :zap: Compute the drop ignore tree in a single pass (#12033) Walk only from the top-most transformed shapes, carry the component root down the walk and memoize each shape transform so the update step reuses it. AI-assisted-by: claude-opus-5-5 --- .../app/main/data/workspace/modifiers.cljs | 123 +++++++++++------- .../logic/ignore_tree_test.cljs | 56 ++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 frontend/test/frontend_tests/logic/ignore_tree_test.cljs diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 328b366bfb..385dbb216d 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -233,55 +233,78 @@ (map #(get objects %)) (reduce get-ignore-tree nil)))) +(defn- cached-transform + [transforms] + (let [cache (js/Map.)] + (fn [shape] + (let [id (dm/get-prop shape :id) + cached (.get cache id)] + (if (and (some? cached) (identical? shape (aget cached 0))) + (aget cached 1) + (let [result (gsh/apply-transform shape (get transforms id))] + (.set cache id #js [shape result]) + result)))))) + (defn calculate-ignore-tree-wasm "Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers" - [transforms objects] + ([transforms objects] + (calculate-ignore-tree-wasm transforms objects (cached-transform transforms))) + ([transforms objects transform] + (let [order (into {} (map-indexed (fn [i id] [id i])) (keys transforms)) - (letfn [(get-ignore-tree - ([ignore-tree shape] - (let [shape-id (dm/get-prop shape :id) - transformed-shape (gsh/apply-transform shape (get transforms shape-id)) + component-shape + (fn [shape parent parent-component] + (cond + (cfh/root? shape) nil + (ctk/instance-root? shape) shape + (and (ctk/instance-head? shape) (not (ctk/in-component-copy? parent))) shape + :else parent-component)) - root - (if (:component-root shape) - shape - (ctn/get-component-shape objects shape {:allow-main? true})) + walk + (fn walk [ignore-tree shape component latest] + (let [index (get order (dm/get-prop shape :id)) + latest (if (and (some? index) (or (nil? latest) (> index (nth latest 0)))) + (if (some? component) + [index component (transform component)] + latest) + latest) + ignore-tree + (if (and (some? latest) (ctk/in-component-copy? shape)) + (let [[_ root transformed-root] latest] + (assoc ignore-tree + (dm/get-prop shape :id) + (check-delta shape root (transform shape) transformed-root))) + ignore-tree)] + (reduce (fn [ignore-tree child] + (walk ignore-tree child (component-shape child shape component) latest)) + ignore-tree + (map (d/getf objects) (:shapes shape))))) - transformed-root - (if (:component-root shape) - transformed-shape - (gsh/apply-transform root (get transforms (:id root))))] + under-transform (volatile! {}) - (get-ignore-tree ignore-tree shape transformed-shape root transformed-root))) - - ([ignore-tree shape root transformed-root] - (let [shape-id (dm/get-prop shape :id) - transformed-shape (gsh/apply-transform shape (get transforms shape-id))] - (get-ignore-tree ignore-tree shape transformed-shape root transformed-root))) - - ([ignore-tree shape transformed-shape root transformed-root] - (let [shape-id (dm/get-prop shape :id) - - ignore-tree - (cond-> ignore-tree - (and (some? root) (ctk/in-component-copy? shape)) - (assoc - shape-id - (check-delta shape root transformed-shape transformed-root))) - - set-child - (fn [ignore-tree child] - (get-ignore-tree ignore-tree child root transformed-root))] - - (->> (:shapes shape) - (map (d/getf objects)) - (reduce set-child ignore-tree)))))] - - ;; we check twice because we want only to search parents of components but once the - ;; tree is traversed we only want to process the objects in components - (->> (keys transforms) - (map #(get objects %)) - (reduce get-ignore-tree nil)))) + under-transform? + (fn under-transform? [id] + (let [parent-id (dm/get-in objects [id :parent-id])] + (cond + (or (nil? parent-id) (= parent-id id)) false + (contains? order parent-id) true + :else + (let [cached (get @under-transform parent-id ::none)] + (if (= cached ::none) + (let [result (under-transform? parent-id)] + (vswap! under-transform assoc parent-id result) + result) + cached)))))] + (->> (keys transforms) + (remove under-transform?) + (keep #(get objects %)) + (reduce (fn [ignore-tree shape] + (let [parent (get objects (:parent-id shape))] + (walk ignore-tree + shape + (component-shape shape parent (ctn/get-component-shape objects parent {:allow-main? true})) + nil))) + nil))))) (defn assoc-position-data [shape position-data old-shape] @@ -866,8 +889,11 @@ :else (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis))) + transform-shape + (cached-transform transforms) + ignore-tree - (calculate-ignore-tree-wasm transforms objects) + (calculate-ignore-tree-wasm transforms objects transform-shape) options (-> params @@ -898,12 +924,9 @@ update-shape (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + (-> shape + (transform-shape) + (ctm/apply-structure-modifiers (dm/get-in modif-tree [(dm/get-prop shape :id) :modifiers])))) undo-id (js/Symbol)] diff --git a/frontend/test/frontend_tests/logic/ignore_tree_test.cljs b/frontend/test/frontend_tests/logic/ignore_tree_test.cljs new file mode 100644 index 0000000000..f625d0009c --- /dev/null +++ b/frontend/test/frontend_tests/logic/ignore_tree_test.cljs @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.logic.ignore-tree-test + (:require + [app.common.files.helpers :as cfh] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.types.component :as ctk] + [app.main.data.workspace.modifiers :as dwm] + [cljs.test :as t :include-macros true])) + +(t/use-fixtures :each {:before cthi/reset-idmap!}) + +(defn- setup + [] + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :board) + (ctho/add-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :copy2-root + :copy2-root-params {:parent-label :board}))] + (:objects (cthf/current-page file)))) + +(defn- ignore-tree + [objects moved-ids] + (let [move (gmt/translate-matrix (gpt/point 10 20))] + (dwm/calculate-ignore-tree-wasm (zipmap moved-ids (repeat move)) objects))) + +(defn- copy-ids + [objects] + (->> (cfh/get-children-ids-with-self objects (cthi/id :copy2-root)) + (filter #(ctk/in-component-copy? (get objects %))))) + +(t/deftest moving-a-board-keeps-its-copies-untouched + (let [objects (setup) + tree (ignore-tree objects (cfh/get-children-ids-with-self objects (cthi/id :board)))] + (t/is (seq (copy-ids objects))) + (t/is (every? #(true? (get tree %)) (copy-ids objects))))) + +(t/deftest moving-a-copy-keeps-its-children-untouched + (let [objects (setup) + tree (ignore-tree objects (cfh/get-children-ids-with-self objects (cthi/id :copy2-root)))] + (t/is (every? #(true? (get tree %)) (copy-ids objects))))) + +(t/deftest moving-a-shape-inside-a-copy-touches-it + (let [objects (setup) + rect-id (last (copy-ids objects)) + tree (ignore-tree objects [rect-id])] + (t/is (false? (get tree rect-id))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 07f4cb0e8a..5a0110743e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -46,6 +46,7 @@ [frontend-tests.logic.copying-and-duplicating-test] [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] + [frontend-tests.logic.ignore-tree-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.logic.path-actions-test] @@ -182,6 +183,7 @@ 'frontend-tests.logic.copying-and-duplicating-test 'frontend-tests.logic.frame-guides-test 'frontend-tests.logic.groups-test + 'frontend-tests.logic.ignore-tree-test 'frontend-tests.logic.nudge-selected-shapes-test 'frontend-tests.logic.path-actions-test 'frontend-tests.logic.path-clipboard-test From 3a0ed9336045c30f4bd2abfc3f24b13a5105ff47 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Fri, 2 Oct 2026 11:08:53 +0200 Subject: [PATCH 016/325] :books: Improve MCP README (#12022) * Make the scope of the usage instructions clear (local usage) * Provide overview of the server's different modes of operation * Improve npm release instructions * Remove deprecated multi-user mode documentation --- mcp/README.md | 62 +++++++++++++++++++++++++------------ mcp/docs/multi-user-mode.md | 41 ------------------------ 2 files changed, 42 insertions(+), 61 deletions(-) delete mode 100644 mcp/docs/multi-user-mode.md diff --git a/mcp/README.md b/mcp/README.md index bc13252c16..0e24eef43c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -40,14 +40,24 @@ but also the supporting Penpot MCP Plugin ## Usage -To use the Penpot MCP server, you must +> [!IMPORTANT] +> **These instructions are for local MCP server usage only!** +> +> The instructions below are for users who want to run the server locally, +> allowing the server to have access to local files, which can be +> relevant for development/coding tasks. +> +> If you need to work only on designs, the centrally hosted **remote MCP server** +> should be considered instead. +> Please refer to the [MCP usage instructions in the help center](https://help.penpot.app/mcp/). + +To run the Penpot MCP server locally, you must * run the MCP server and connect your AI client to it, * run the web server providing the Penpot MCP plugin, and * open the Penpot MCP plugin in Penpot and connect it to the MCP server. Follow the steps below to enable the integration. - ### Prerequisites The project requires [Node.js](https://nodejs.org/) 20 or later (tested with v22.x). @@ -161,15 +171,15 @@ This bootstrap command will: > (If you are using a standard commercial model, it almost certainly supports vision already.) By default, the server provides a Streamable HTTP endpoint at `http://localhost:4401/mcp`. +Simply configure the client to connect the MCP server by providing the respective URL. The legacy `/sse` and `/messages` endpoints are no longer supported. Clients using the legacy SSE transport must switch to Streamable HTTP at `/mcp`. You can change the port by setting the `PENPOT_MCP_SERVER_PORT` environment variable before starting the server. This endpoint can be used directly by MCP clients that support Streamable HTTP. -Simply configure the client to connect the MCP server by providing the respective URL. -#### Configuring your client +#### Automatically configuring your client You can configure your client with the [add-mcp](https://github.com/neon-solutions/add-mcp) helper. Simply call @@ -286,22 +296,33 @@ The Penpot MCP server can be configured using environment variables. |-------------------------------------------|-----------------------------------------------------------------------------------------|--------------| | `PENPOT_MCP_PLUGIN_SERVER_HOST` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | -## Beyond Local Execution +## The Server's Modes of Operation -The above instructions describe how to run the MCP server and plugin server locally. +The above instructions describe how to run the MCP server and plugin server locally, +for a single user – its simplest mode of operation. -The Penpot MCP server can also support multiple remote users simultaneously -in [multi-user mode](docs/multi-user-mode.md). +The server supports additional modes of operation, which are intended for +non-local usage (and which are relevant to hosted deployments only). +Some of the aforementioned configuration options control these modes. -To run the server remotely (even for a single user), -you may set the following environment variables to configure the two servers -(MCP server & plugin server) appropriately: - * `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating - in remote mode, with local file system access disabled. - * `PENPOT_MCP_SERVER_HOST` and `PENPOT_MCP_PLUGIN_SERVER_HOST`: - Set these according to your requirements for remote connectivity. - To bind all interfaces, use `0.0.0.0` (use caution in untrusted networks). +* **remote mode**: + In remote mode, the server is not assumed to be accessed by a local user on the same machine, + with corresponding limitations being enforced (tools offering local file system access are restricted/disabled). +* **multi-user mode**: + In multi-user mode, the server can be accessed by multiple users simultaneously. + This mode always implies *remote mode*. + User tokens are passed to the server both when connecting an MCP client and when establishing + plugin connections, associating each connection with the respective user and allowing tasks to be routed to the correct Penpot instance. + This mode is intended for hosted deployments using the integrated version of the Penpot MCP plugin, + which automatically sends the user token to the server when establishing WebSocket connections. + It is enabled by running the server with the `--multi-user` flag. + +* **multi-instance mode**: + In multi-instance mode, multiple instances of the MCP server can be run in parallel (load balancing). + This can result in connections from the same user being owned by different server instances, + making it necessary to dispatch tasks to the correct instance. This is handled through Redis, + and the mode is thus enabled by setting the environment variable providing the Redis connection URI. ## Development @@ -312,7 +333,8 @@ you may set the following environment variables to configure the two servers - Ensure that at least the major, minor and patch components of the version are always up-to-date. - The MCP plugin assumes that a mismatch between the MCP version and the Penpot version (as returned by the API) indicates incompatibility, resulting in the display of a warning message in the plugin UI. -* Packaging and publishing: - 1. Ensure release version is set correctly in package.json (call `bash scripts/set-version` to update it automatically) - 2. Create npm package: `bash scripts/pack` (creates `penpot-mcp-.tgz` for publishing) - 3. Publish to npm: `npm publish penpot-mcp-.tgz --access public` +* Packaging and publishing: + 1. Ensure that the API type data is up-to-date (see above). + 2. Ensure release version is set correctly in package.json (call `bash scripts/set-version` to update it automatically) + 3. Create npm package: `bash scripts/pack` (creates `penpot-mcp-.tgz` for publishing) + 4. Publish to npm: `npm publish penpot-mcp-.tgz --access public` diff --git a/mcp/docs/multi-user-mode.md b/mcp/docs/multi-user-mode.md deleted file mode 100644 index b4471d18b9..0000000000 --- a/mcp/docs/multi-user-mode.md +++ /dev/null @@ -1,41 +0,0 @@ -# Multi-User Mode - -> [!WARNING] -> Multi-user mode is under development and not yet fully integrated. -> This information is provided for testing purposes only. - -The Penpot MCP server supports a multi-user mode, allowing multiple Penpot users -to connect to the same MCP server instance simultaneously. -This supports remote deployments of the MCP server, without requiring each user -to run their own server instance. - -## Limitations - -Multi-user mode has the limitation that tools which read from or write to -the local file system are not supported, as the server cannot access -the client's file system. This affects the import and export tools. - -## Running Components in Multi-User Mode - -To run the MCP server and the Penpot MCP plugin in multi-user mode (for testing), -you can use the following command: - -```shell -npm run bootstrap:multi-user -``` - -This will: -* launch the MCP server in multi-user mode (adding the `--multi-user` flag), -* build and launch the Penpot MCP plugin server in multi-user mode. - -See the package.json scripts for both `mcp-server` and `penpot-plugin` for details. - -In multi-user mode, users are required to be authenticated via a token. - -* This token is provided in the URL used to connect to the MCP server, - e.g. `http://localhost:4401/mcp?userToken=USER_TOKEN`. -* The same token must be provided when connecting the Penpot MCP plugin - to the MCP server. - In the future, the token will, most likely be generated by Penpot and - provided to the plugin automatically. - :warning: For now, it is hard-coded in the plugin's source code for testing purposes. From 13a9da3bc3a47bfd4660fdd9d887a92cd7c33ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 11:22:19 +0200 Subject: [PATCH 017/325] :zap: Reduce commit cost when dropping large selections (#12032) Collect children ids into one transient vector and track WASM shape changes in a JS Map instead of a volatile persistent map. AI-assisted-by: claude-opus-5-5 --- common/src/app/common/files/helpers.cljc | 17 +++++++++-------- frontend/src/app/main/data/changes.cljs | 4 ++-- frontend/src/app/render_wasm/shape.cljs | 12 ++++++++---- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/common/src/app/common/files/helpers.cljc b/common/src/app/common/files/helpers.cljc index 2c8ff25600..2b83fa75c5 100644 --- a/common/src/app/common/files/helpers.cljc +++ b/common/src/app/common/files/helpers.cljc @@ -162,14 +162,15 @@ ([objects id {:keys [ignore-children-fn] ;;ignore-children-fn should receive a shape and return a boolean :or {ignore-children-fn (constantly false)}}] - (letfn [(get-children-ids-rec [id processed] - (when-not (contains? processed id) - (when-let [shapes (as-> (get objects id) $ - (:shapes $) - (remove ignore-children-fn $) - (some-> $ vec))] - (into shapes (mapcat #(get-children-ids-rec % (conj processed id))) shapes))))] - (get-children-ids-rec id #{})))) + (letfn [(collect [result id processed] + (if (contains? processed id) + result + (let [shapes (into [] (remove ignore-children-fn) (dm/get-in objects [id :shapes])) + processed (conj processed id)] + (reduce #(collect %1 %2 processed) + (reduce conj! result shapes) + shapes))))] + (persistent! (collect (transient []) id #{}))))) (defn get-children-ids-with-self [objects id] diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 865779e5dc..bd8afa6ff7 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -133,14 +133,14 @@ (if (and (not ignore-wasm?) (features/active-feature? state "render-wasm/v1")) ;; Update the wasm model - (let [shape-changes (volatile! {}) + (let [shape-changes (js/Map.) state (binding [cts/*shape-changes* shape-changes] (update-in state [:files file-id :data] apply-changes))] (let [objects (dm/get-in state [:files file-id :data :pages-index (:current-page-id state) :objects])] - (wasm.shape/process-shape-changes! objects @shape-changes)) + (wasm.shape/process-shape-changes! objects (es6-iterator-seq (.entries shape-changes)))) state) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 3d895841c8..d94bd8d070 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -348,10 +348,15 @@ ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) +(defn- track-change! + [self k] + (when-let [changes shape/*shape-changes*] + (let [id (:id self)] + (.set changes id (conj* (.get changes id) k))))) + (defn- impl-assoc [self k v] - (when shape/*shape-changes* - (vswap! shape/*shape-changes* update (:id self) conj* k)) + (track-change! self k) (case k :id @@ -379,8 +384,7 @@ (defn- impl-dissoc [self k] - (when shape/*shape-changes* - (vswap! shape/*shape-changes* update (:id self) conj* k)) + (track-change! self k) (case k :id From c148ba5267398a40cb936061cb8ef7c8f18f36a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 11:39:24 +0200 Subject: [PATCH 018/325] :zap: Rebuild drag tiles per moved subtree (#12030) WASM receives only the transforms that differ from the parent (bool operands and masks are kept), rebuilds tiles once per moved subtree and invalidates only the previous frame coverage. --- .../app/main/data/workspace/modifiers.cljs | 19 ++++++- render-wasm/src/main.rs | 2 + render-wasm/src/render.rs | 36 +++++++++++- render-wasm/src/state/shapes_pool.rs | 56 +++++++++++++------ 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 385dbb216d..5386fef3df 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -726,6 +726,21 @@ (vreset! cache (translate-selrect computed (- tx) (- ty))) computed)))) +(defn- without-inherited-transforms + "Drops the entries that repeat their parent's transform. Only for WASM, which applies it to + the subtree; the UI looks transforms up per shape. WASM does not expand into bool operands + nor masks, so those are kept." + [objects modifiers] + (let [transforms (into {} modifiers)] + (into [] + (remove (fn [[id transform]] + (let [parent (get objects (dm/get-in objects [id :parent-id]))] + (and (= transform (get transforms (:id parent))) + (not (cfh/bool-shape? parent)) + (not (and (cfh/mask-shape? parent) + (= id (first (:shapes parent))))))))) + modifiers))) + #_:clj-kondo/ignore (defn set-wasm-modifiers [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis @@ -784,7 +799,9 @@ (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)] (if (seq propagated) propagated root-modifiers)))] (when wasm-ready? - (wasm.api/set-modifiers modifiers)) + (wasm.api/set-modifiers + (cond->> modifiers + translation? (without-inherited-transforms (dsh/lookup-page-objects state))))) (let [ids (into [] xf:map-key geometry-entries) selrect (when wasm-ready? (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 125f553a0b..01a2217a07 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -354,6 +354,7 @@ pub extern "C" fn set_view_end() -> Result<()> { pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); let render_state = get_render_state(); + render_state.moving_extrects.clear(); render_state.options.set_fast_mode(true); render_state.options.set_interactive_transform(true); performance::end_measure!("set_modifiers_start"); @@ -369,6 +370,7 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { pub extern "C" fn set_modifiers_end() -> Result<()> { performance::begin_measure!("set_modifiers_end"); let render_state = get_render_state(); + render_state.moving_extrects.clear(); render_state.options.set_fast_mode(false); render_state.options.set_interactive_transform(false); performance::end_measure!("set_modifiers_end"); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 26a99a499e..f95f324bfb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -472,6 +472,8 @@ pub(crate) struct RenderState { /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during /// drag, entries for the moved top-level selection are ensured here pub backbuffer_crop_cache: HashMap, + /// Extrect of each moved shape at the previous interactive frame; reset per gesture. + pub moving_extrects: HashMap, /// Whether we've already forced a GPU flush+submit before a tile-atlas /// snapshot this render. The first snapshot of a pass can otherwise capture /// a tile before its text glyph uploads complete (blank first/center tile). @@ -674,6 +676,7 @@ impl RenderState { interactive_target_seeded: false, preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), + moving_extrects: HashMap::default(), tile_atlas_flushed: false, drop_shadows_ops_warmed: false, drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), @@ -4540,7 +4543,8 @@ impl RenderState { // // We intentionally skip this when there is NO modifier so that plain // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. - if tree.get_modifier(&shape.id).is_some() { + // Once per gesture: later frames only move away from pixels already cleared. + if tree.get_modifier(&shape.id).is_some() && !self.moving_extrects.contains_key(&shape.id) { if let Some(raw_shape) = tree.get_raw(&shape.id) { let old_extrect = raw_shape.extrect(tree, 1.0); self.surfaces @@ -4816,7 +4820,7 @@ impl RenderState { } if self.options.is_interactive_transform() { - self.update_tiles_shapes(ids, tree)?; + self.update_moving_tiles(ids, tree); } else { let ancestors = all_with_ancestors(ids, tree, false); self.update_tiles_shapes(&ancestors, tree)?; @@ -4824,6 +4828,34 @@ impl RenderState { Ok(()) } + /// Evicts each moved shape's previous-frame ∪ current coverage. Tiles it left + /// before the previous frame were already repainted without it. + fn update_moving_tiles(&mut self, ids: &[Uuid], tree: ShapesPoolRef) { + let mut next = HashMap::with_capacity(ids.len()); + for id in ids { + let Some(shape) = tree.get(id) else { + continue; + }; + let prev_extrect = self.moving_extrects.get(id).copied(); + self.invalidate_shape_and_update_tiles(shape, tree, prev_extrect); + let extrect = self.get_cached_extrect(shape, tree, 1.0); + next.insert(*id, extrect); + } + // Shapes that stopped moving (e.g. flex siblings no longer reflowed) leave their + // last position behind and return to one painted without them; dropping them also + // restarts their coverage if they re-enter. + let previous = std::mem::replace(&mut self.moving_extrects, next); + for (id, rect) in previous { + if !self.moving_extrects.contains_key(&id) { + self.surfaces.invalidate_cached_tiles_intersecting(rect); + if let Some(shape) = tree.get(&id) { + let extrect = self.get_cached_extrect(shape, tree, 1.0); + self.surfaces.invalidate_cached_tiles_intersecting(extrect); + } + } + } + } + pub fn get_scale(&self) -> f32 { // During export, use the export scale instead of the workspace zoom. if let Some((_, export_scale)) = self.export_context { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f39825ebaf..be0a34e8eb 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -319,37 +319,50 @@ impl ShapesPoolImpl { }) .collect(); - let mut descendants_idxs: Vec = Vec::new(); for (root_idx, matrix) in root_pairs { - for descendant_idx in self.collect_all_descendants(root_idx) { - if let std::collections::hash_map::Entry::Vacant(e) = - modifiers_with_idx.entry(descendant_idx) - { - e.insert(matrix); - descendants_idxs.push(descendant_idx); - } + for descendant_idx in self.collect_inheriting_descendants(root_idx, &modifiers_with_idx) + { + modifiers_with_idx.insert(descendant_idx, matrix); } } self.modifiers = modifiers_with_idx; - for descendant_idx in descendants_idxs { - self.modified_shape_cache - .insert(descendant_idx, OnceCell::new()); + for &idx in self.modifiers.keys() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } - // Compute ancestors before consuming `ids` so we can move it into - // `modifier_uuids` without a clone. - let all_ids = shapes::all_with_ancestors(&ids, self, true); + // Snapped translations arrive propagated to every descendant. Keep only the shapes + // that move relative to their parent, so tiles are rebuilt per moved subtree. + let roots: Vec = ids + .into_iter() + .filter(|uuid| !self.moves_with_parent(uuid)) + .collect(); - for uuid in all_ids { + for uuid in shapes::all_with_ancestors(&roots, self, true) { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { self.modified_shape_cache.insert(idx, OnceCell::new()); } } // rebuild_modifier_tiles doesn't process every descendant individually. - self.modifier_uuids = ids; + self.modifier_uuids = roots; + } + + fn moves_with_parent(&self, id: &Uuid) -> bool { + let Some(&idx) = self.uuid_to_idx.get(id) else { + return false; + }; + let Some(&parent_idx) = self.shapes[idx] + .parent_id + .as_ref() + .and_then(|parent_id| self.uuid_to_idx.get(parent_id)) + else { + return false; + }; + self.modifiers + .get(&parent_idx) + .is_some_and(|parent| self.modifiers.get(&idx) == Some(parent)) } pub fn set_structure(&mut self, structure: HashMap>) { @@ -473,7 +486,13 @@ impl ShapesPoolImpl { } } - fn collect_all_descendants(&self, idx: usize) -> Vec { + /// Descendants of `idx` without a modifier of their own, stopping at the subtrees of + /// those that have one: they inherit from their nearest ancestor with a modifier. + fn collect_inheriting_descendants( + &self, + idx: usize, + modifiers: &HashMap, + ) -> Vec { let mut result = Vec::new(); let mut queue: VecDeque<&Uuid> = VecDeque::new(); let shape = &self.shapes[idx]; @@ -482,6 +501,9 @@ impl ShapesPoolImpl { } while let Some(child_id) = queue.pop_front() { if let Some(&child_idx) = self.uuid_to_idx.get(child_id) { + if modifiers.contains_key(&child_idx) { + continue; + } result.push(child_idx); let child_shape = &self.shapes[child_idx]; for grandchild_id in child_shape.children_ids_iter(false) { From 822dbd6517cc4bd7c64fd73098f813a4f437194f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 12:05:25 +0200 Subject: [PATCH 019/325] :zap: Speed up sidebar menus for large selections (#12028) Reuse extracted colors for unchanged shapes and cache the text attrs read from each text content. --- .../src/app/main/data/workspace/colors.cljs | 76 +++++++++---------- .../options/menus/color_selection.cljs | 33 +++++++- .../sidebar/options/shapes/multiple.cljs | 16 +++- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/text_attrs_multiple_test.cljs | 40 ++++++++++ 5 files changed, 124 insertions(+), 43 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index c99f98239b..be0f95156c 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -1351,6 +1351,43 @@ :shape-id (:shape-id fill) :index (:index fill)}))) +(defn shape-colors + [shape file-id libraries] + (let [applied-tokens (:applied-tokens shape) + applied-fill (get applied-tokens :fill) + applied-stroke (get applied-tokens :stroke-color) + fills (:fills shape) + strokes (:strokes shape) + shadows (:shadow shape) + shape-id (:id shape) + + fills* (map-indexed + (fn [index fill] + (cond-> (assoc fill :shape-id shape-id :index index) + (and (zero? index) applied-fill) + (assoc :has-token-applied true + :token-name applied-fill))) + fills) + + strokes* (map-indexed + (fn [index stroke] + (cond-> (assoc stroke :shape-id shape-id :index index) + (and (zero? index) applied-stroke) + (assoc :has-token-applied true + :token-name applied-stroke))) + strokes) + + shadows* (map-indexed #(assoc %2 :shape-id shape-id :index %1) shadows)] + (if (= :text (:type shape)) + (-> [] + (into (keep #(stroke->color-att % file-id libraries)) strokes*) + (into (map #(shadow->color-attr % file-id libraries)) shadows*) + (into (extract-text-colors shape file-id libraries))) + (-> [] + (into (keep #(fill->color-att % file-id libraries)) fills*) + (into (keep #(stroke->color-att % file-id libraries)) strokes*) + (into (map #(shadow->color-attr % file-id libraries)) shadows*))))) + (defn extract-all-colors "Extracts color information from a list of shapes, including fills, strokes, and shadows. If a shape has applied tokens of type :fill or :stroke-color, the first fill or stroke @@ -1366,41 +1403,4 @@ Returns: A vector of color attribute maps with metadata for each shape." [shapes file-id libraries] - (reduce - (fn [result shape] - (let [applied-tokens (:applied-tokens shape) - applied-fill (get applied-tokens :fill) - applied-stroke (get applied-tokens :stroke-color) - fills (:fills shape) - strokes (:strokes shape) - shadows (:shadow shape) - shape-id (:id shape) - - fills* (map-indexed - (fn [index fill] - (cond-> (assoc fill :shape-id shape-id :index index) - (and (zero? index) applied-fill) - (assoc :has-token-applied true - :token-name applied-fill))) - fills) - - strokes* (map-indexed - (fn [index stroke] - (cond-> (assoc stroke :shape-id shape-id :index index) - (and (zero? index) applied-stroke) - (assoc :has-token-applied true - :token-name applied-stroke))) - strokes) - - shadows* (map-indexed #(assoc %2 :shape-id shape-id :index %1) shadows)] - (if (= :text (:type shape)) - (-> result - (into (keep #(stroke->color-att % file-id libraries)) strokes*) - (into (map #(shadow->color-attr % file-id libraries)) shadows*) - (into (extract-text-colors shape file-id libraries))) - (-> result - (into (keep #(fill->color-att % file-id libraries)) fills*) - (into (keep #(stroke->color-att % file-id libraries)) strokes*) - (into (map #(shadow->color-attr % file-id libraries)) shadows*))))) - [] - shapes)) + (into [] (mapcat #(shape-colors % file-id libraries)) shapes)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index cda718388c..5db6007cf7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.types.library :as ctl] [app.main.data.workspace.colors :as dwc] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.tokens.application :as dwta] @@ -17,6 +18,17 @@ [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) +(defn- cached-shape-colors + [prev-cache cache libraries-colors shape file-id libraries] + (let [inputs [(:type shape) (:fills shape) (:strokes shape) (:shadow shape) + (:applied-tokens shape) (:content shape) file-id libraries-colors] + [cached-inputs cached] (when prev-cache (.get prev-cache (:id shape))) + colors (if (and cached-inputs (every? true? (map identical? cached-inputs inputs))) + cached + (dwc/shape-colors shape file-id libraries))] + (.set cache (:id shape) [inputs colors]) + colors)) + (defn- prepare-colors "Prepares and groups extracted color information from shapes. Input: @@ -62,8 +74,11 @@ This structure allows fast lookups of all shapes using the same visual color, regardless of whether it comes from local fills, strokes or shadow-colors." - [shapes file-id libraries] - (let [data (into [] (remove nil?) (dwc/extract-all-colors shapes file-id libraries)) + [prev-cache cache libraries-colors shapes file-id libraries] + (let [data (into [] + (comp (mapcat #(cached-shape-colors prev-cache cache libraries-colors % file-id libraries)) + (remove nil?)) + shapes) groups (d/group-by :attrs #(dissoc % :attrs) data) ;; Unique color attribute maps @@ -98,9 +113,19 @@ (mf/defc color-selection-menu* {::mf/wrap [#(mf/memo' % (mf/check-props ["shapes"]))]} [{:keys [shapes file-id libraries]}] - (let [{:keys [groups library-colors colors token-colors]} + (let [cache* (mf/use-ref nil) + libraries-colors* (mf/use-ref nil) + + {:keys [groups library-colors colors token-colors]} (mf/with-memo [file-id shapes libraries] - (prepare-colors shapes file-id libraries)) + (let [current (mapv (fn [[id library]] [id (ctl/get-colors (:data library))]) libraries) + previous (mf/ref-val libraries-colors*) + libraries-colors (if (= previous current) previous current) + cache (js/Map.) + result (prepare-colors (mf/ref-val cache*) cache libraries-colors shapes file-id libraries)] + (mf/set-ref-val! libraries-colors* libraries-colors) + (mf/set-ref-val! cache* cache) + result)) open* (mf/use-state true) open? (deref open*) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 8c4cad66b7..ce7f53496d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -262,6 +262,20 @@ (assoc :layout-padding (merge-layout-padding values shape-values)))] (promote-simple-layout-padding-type merged-values))) +(def ^:private content-attrs-cache (js/WeakMap.)) + +(defn- get-content-attrs + "Moving a text keeps its `:content`, so the attrs read from it are cached by content." + [{:keys [content] :as shape} attrs] + (if (some? content) + (let [[cached-attrs cached] (.get content-attrs-cache content)] + (if (identical? cached-attrs attrs) + cached + (let [result (attrs/get-text-attrs-multi shape txt/default-text-attrs attrs)] + (.set content-attrs-cache content [attrs result]) + result))) + (attrs/get-text-attrs-multi shape txt/default-text-attrs attrs))) + (defn get-attrs* "Given a group of attributes that we want to extract and the shapes to extract them from returns a list of tuples [id, values] with the extracted properties for the shapes that @@ -359,7 +373,7 @@ (let [shape-attrs (select-keys shape attrs) content-attrs - (attrs/get-text-attrs-multi shape txt/default-text-attrs attrs) + (get-content-attrs shape attrs) new-values (-> values diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5a0110743e..349cdfd029 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -117,6 +117,7 @@ [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] [frontend-tests.ui.stroke-menu-test] + [frontend-tests.ui.text-attrs-multiple-test] [frontend-tests.util-clipboard-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] @@ -254,6 +255,7 @@ 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test 'frontend-tests.ui.stroke-menu-test + 'frontend-tests.ui.text-attrs-multiple-test 'frontend-tests.util-clipboard-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test diff --git a/frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs b/frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs new file mode 100644 index 0000000000..4bf8018be6 --- /dev/null +++ b/frontend/test/frontend_tests/ui/text_attrs_multiple_test.cljs @@ -0,0 +1,40 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.text-attrs-multiple-test + (:require + [app.main.ui.workspace.sidebar.options.shapes.multiple :as multiple] + [cljs.test :as t :include-macros true])) + +(defn- content + [font-family] + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "a" :font-family font-family}]}]}]}) + +(def ^:private inter (content "Inter")) + +(def ^:private objects + {:board-1 {:id :board-1 :type :frame :shapes [:inner :text-1]} + :inner {:id :inner :type :frame :shapes [:text-2]} + :board-2 {:id :board-2 :type :frame :shapes [:text-3 :rect]} + :text-1 {:id :text-1 :type :text :content inter :applied-tokens {:typography "body"}} + :text-2 {:id :text-2 :type :text :content inter :applied-tokens {:typography "body"}} + :text-3 {:id :text-3 :type :text :content (content "Roboto") :applied-tokens {:typography "body"}} + :rect {:id :rect :type :rect}}) + +(defn- text-attrs + [labels objects] + (multiple/get-attrs* (map objects labels) objects :text)) + +(t/deftest boards-read-nested-text-values + (let [[ids values tokens] (text-attrs [:board-1] objects)] + (t/is (= [:text-2 :text-1] ids)) + (t/is (= "Inter" (:font-family values))) + (t/is (= "body" (:typography tokens)))) + (let [[_ values] (text-attrs [:board-1 :board-2] objects)] + (t/is (= :multiple (:font-family values))))) From da55d6723c2e5da9312f891e259acf8ea0ce0378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 12:24:04 +0200 Subject: [PATCH 020/325] :zap: Reduce per-frame CLJS work while dragging (#12027) Memoize snap points and frame titles on the base objects and compute the drag target frames once per gesture. --- .../app/main/data/workspace/transforms.cljs | 25 ++++++++++++++++--- .../src/app/main/ui/workspace/viewport.cljs | 2 ++ .../ui/workspace/viewport/snap_points.cljs | 5 ++-- .../main/ui/workspace/viewport/widgets.cljs | 6 +++-- .../app/main/ui/workspace/viewport_wasm.cljs | 2 ++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 5e5c033329..e5bcaa1c8a 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -839,8 +839,9 @@ exclude-frames (into #{} - (filter (partial cfh/frame-shape? objects)) - (cfh/selected-with-children objects selected)) + (comp (mapcat #(cfh/get-children-ids-with-self objects %)) + (filter (partial cfh/frame-shape? objects))) + selected) exclude-frames-siblings (into exclude-frames @@ -848,6 +849,21 @@ (filter (partial ctl/any-layout-immediate-child-id? objects))) selected) + ;; Frame lookups per pointer move only test frames that can be a target + frames + (ctst/get-frames objects) + + with-target-frames + (fn [excluded] + (vary-meta objects assoc ::ctst/index-frames + (into [] (remove #(contains? excluded (:id %))) frames))) + + target-objects + (with-target-frames exclude-frames) + + target-objects-siblings + (with-target-frames exclude-frames-siblings) + position (->> ms/mouse-position (rx/map #(gpt/to-vec from-position %))) @@ -891,8 +907,9 @@ (rx/map (fn [[move-vector mod?]] (let [position (gpt/add from-position move-vector) - exclude-frames (if mod? exclude-frames exclude-frames-siblings) - target-frame (ctst/top-nested-frame objects position exclude-frames) + target-frame (if mod? + (ctst/top-nested-frame target-objects position) + (ctst/top-nested-frame target-objects-siblings position)) [target-frame _] (ctn/find-valid-parent-and-frame-ids target-frame objects shapes false libraries parent-validation-cache) flex-layout? (ctl/flex-layout? objects target-frame) grid-layout? (ctl/grid-layout? objects target-frame) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index cd2ef3ca88..ad3e1b987f 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -592,6 +592,7 @@ [:> widgets/frame-titles* {:objects base-objects + :base-objects base-objects :selected selected :zoom zoom :is-show-artboard-names show-artboard-names? @@ -637,6 +638,7 @@ :page-id page-id :selected selected :objects objects-modified + :base-objects base-objects :focus focus}]) (when show-snap-distance? diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index 409a6ad153..063890d84e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -158,12 +158,13 @@ (mf/defc snap-points* {::mf/wrap [mf/memo]} - [{:keys [layout zoom objects selected page-id drawing focus]}] + [{:keys [layout zoom objects base-objects selected page-id drawing focus]}] (dm/assert! (set? selected)) (let [shapes (into [] (keep (d/getf objects)) selected) filter-shapes - (into selected (mapcat #(cfh/get-children-ids objects %)) selected) + (mf/with-memo [base-objects selected] + (into #{} (mapcat #(cfh/get-children-ids-with-self base-objects %)) selected)) remove-snap-base? (mf/with-memo [layout filter-shapes objects focus] diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index aa83faf963..357f1faf34 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -259,10 +259,12 @@ (mf/defc frame-titles* {::mf/wrap [mf/memo]} - [{:keys [objects zoom selected focus is-show-artboard-names + [{:keys [objects base-objects zoom selected focus is-show-artboard-names on-frame-enter on-frame-leave on-frame-select]}] (let [selected (or selected #{}) - shapes (ctt/get-frames objects {:skip-copies? true :ignore-index? true}) + frame-ids (mf/with-memo [base-objects] + (vec (ctt/get-frames-ids base-objects {:skip-copies? true :ignore-index? true}))) + shapes (into [] (keep (d/getf objects)) frame-ids) shapes (if (dbg/enabled? :shape-titles) (into (set shapes) (map (d/getf objects)) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 3705839275..e338f2366b 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -870,6 +870,7 @@ (when-not shapes-loading? [:> widgets/frame-titles* {:objects objects-modified + :base-objects base-objects :selected selected :zoom zoom :is-show-artboard-names show-artboard-names? @@ -918,6 +919,7 @@ :page-id page-id :selected selected :objects objects-modified + :base-objects base-objects :focus focus}]) (when show-snap-distance? From d531c0050b89bc311ba3ce2291be988bab781e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 12:39:18 +0200 Subject: [PATCH 021/325] :zap: Skip builder local apply on plain translations (#12036) Translations that touch no group-like shape or ancestor do not need the builder objects updated, since only parent resizing reads them. AI-assisted-by: claude-opus-5-5 --- .../src/app/common/files/changes_builder.cljc | 8 +++++- .../src/app/main/data/workspace/shapes.cljs | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 1e71d05b4d..3317d91653 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -187,7 +187,8 @@ (assert (check-changes changes) "expected valid changes") - (if-let [file-data (::file-data (meta changes))] + (if-let [file-data (and (not (::skip-local? (meta changes))) + (::file-data (meta changes)))] (let [library-data (::library-data (meta changes)) index (::applied-changes-count (meta changes)) redo-changes (:redo-changes changes) @@ -206,6 +207,11 @@ ::applied-changes-count (count redo-changes))) changes)) +(defn skip-local + "Stop applying changes to the builder objects." + [changes skip?] + (vary-meta changes assoc ::skip-local? skip?)) + (defn- without-changes-local "Append changes through `f` without applying them to the mounted page's working state." diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 0b195be68b..587956c084 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -184,6 +184,26 @@ ::update-shapes-buffer-event)))) (rx/empty))))))) +(defn- any-group-like? + "Whether any of `ids` or their ancestors is group-like." + [objects ids] + (let [cache (js/Map.) + group-like? + (fn group-like? [id] + (let [cached (.get cache id)] + (if (some? cached) + cached + (let [shape (get objects id) + parent-id (:parent-id shape) + result (boolean + (or (cfh/group-like-shape? shape) + (and (some? parent-id) + (not= parent-id id) + (group-like? parent-id))))] + (.set cache id result) + result))))] + (boolean (some group-like? ids)))) + (defn update-shapes ([ids update-fn] (update-shapes ids update-fn nil)) @@ -212,10 +232,15 @@ objects (dsh/lookup-page-objects state page-id) ids (into [] (filter some?) ids) + ;; Parent resizing only reads the builder objects for groups + skip-local? + (and translation? (not (any-group-like? objects ids))) + changes (-> (pcb/empty-changes it page-id) (pcb/set-save-undo? save-undo?) (pcb/set-stack-undo? stack-undo?) + (pcb/skip-local skip-local?) (cls/generate-update-shapes ids update-fn objects From 8efbd9b5d3fc7f3b0b174efcc6c665033318dcad Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Fri, 2 Oct 2026 14:22:52 +0200 Subject: [PATCH 022/325] :tada: Allow MCP to work with multiple files in parallel (#11927) * :tada: Add multi-file session routing to the MCP server Session routing: - Track each user's Penpot connections by session ID. - Dispatch explicit sessions directly; otherwise discover the sole session. - Return a retryable error when discovery fails or is incomplete. Plugin and tools: - Initialize connections with a fresh session ID and file metadata. - Share the optional sessionId schema and place it last in tool inputs. Validation: - 84 tests, server type checking, and MCP formatting checks pass. - Disable test logging to avoid the logging worker shutdown hang. AI-assisted-by: gpt-6 * :tada: Support independent MCP connections - Require explicit connection intent for each integrated workspace. - Derive short session IDs from the Penpot app instance and file. - Show and copy session IDs in the MCP menu and standalone plugin UI. - Stop stale callbacks and reconnect attempts after disconnect. Validate with frontend and plugin tests, type checking, live multi-tab checks, and standalone UI checks with a simulated connection. Server tests pass with --test-force-exit; the normal runner can hang on shutdown. AI-assisted-by: gpt-6 * :books: Document multi-file MCP sessions - Explain independent connections, session selection, and copying IDs. - Describe session ID lifetimes for integrated and standalone plugins. - Correct browser focus, connection, and recovery guidance. Validate with the documentation site build and diff checks. AI-assisted-by: gpt-6 * :sparkles: Change behavior on session ID duplication * :sparkles: Open MCP toolbar menu on hover and match menu style --------- Co-authored-by: alonso.torres Co-authored-by: elhombretecla --- .serena/memories/mcp/core.md | 5 +- docs/mcp/index.md | 46 +- frontend/src/app/main/data/workspace/mcp.cljs | 121 ++--- .../app/main/ui/workspace/top_toolbar.cljs | 93 +++- .../app/main/ui/workspace/top_toolbar.scss | 24 +- .../data/workspace_mcp_test.cljs | 67 ++- frontend/translations/en.po | 12 + mcp/README.md | 20 +- mcp/packages/common/src/types.ts | 13 + mcp/packages/plugin/index.html | 16 + mcp/packages/plugin/public/manifest.json | 2 +- .../plugin/src/ConnectionMetadata.test.ts | 293 ++++++++++++ mcp/packages/plugin/src/SessionId.test.ts | 14 + mcp/packages/plugin/src/SessionId.ts | 21 + .../plugin/src/SessionIdDisplay.test.ts | 77 ++++ mcp/packages/plugin/src/SessionIdDisplay.ts | 41 ++ mcp/packages/plugin/src/index.d.ts | 3 +- mcp/packages/plugin/src/main.ts | 101 ++++- mcp/packages/plugin/src/plugin.ts | 25 +- mcp/packages/plugin/src/style.css | 11 + mcp/packages/server/package.json | 2 +- mcp/packages/server/src/PenpotConnection.ts | 22 + .../server/src/PenpotMcpServer.http.test.ts | 17 + .../server/src/PluginBridge.sessions.test.ts | 145 ++++++ mcp/packages/server/src/PluginBridge.ts | 418 +++++++++--------- mcp/packages/server/src/PluginTask.ts | 2 +- mcp/packages/server/src/RedisBridge.test.ts | 245 ++++++++++ mcp/packages/server/src/RedisBridge.ts | 185 +++++++- mcp/packages/server/src/Tool.ts | 10 + .../server/src/UserPenpotConnections.ts | 43 ++ .../MultiInstanceTaskDispatcher.test.ts | 371 ++++++++++++++++ .../dispatch/MultiInstanceTaskDispatcher.ts | 216 +++++++++ .../dispatch/SingleInstanceTaskDispatcher.ts | 27 ++ .../server/src/dispatch/TaskDispatcher.ts | 49 ++ .../server/src/tools/ExecuteCodeTool.ts | 5 +- .../server/src/tools/ExportShapeTool.ts | 5 +- .../server/src/tools/ImportImageTool.ts | 5 +- 37 files changed, 2431 insertions(+), 341 deletions(-) create mode 100644 mcp/packages/plugin/src/ConnectionMetadata.test.ts create mode 100644 mcp/packages/plugin/src/SessionId.test.ts create mode 100644 mcp/packages/plugin/src/SessionId.ts create mode 100644 mcp/packages/plugin/src/SessionIdDisplay.test.ts create mode 100644 mcp/packages/plugin/src/SessionIdDisplay.ts create mode 100644 mcp/packages/server/src/PenpotConnection.ts create mode 100644 mcp/packages/server/src/PluginBridge.sessions.test.ts create mode 100644 mcp/packages/server/src/RedisBridge.test.ts create mode 100644 mcp/packages/server/src/UserPenpotConnections.ts create mode 100644 mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.test.ts create mode 100644 mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.ts create mode 100644 mcp/packages/server/src/dispatch/SingleInstanceTaskDispatcher.ts create mode 100644 mcp/packages/server/src/dispatch/TaskDispatcher.ts diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 9d55a13105..feb218b5d3 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -98,5 +98,6 @@ For parallel devenvs, prefer same-origin MCP routing: each Penpot instance shoul ## Plugin reconnect policy - The plugin treats WebSocket close code `1008` (policy violation) as terminal: it stops auto-reconnecting and stays disconnected until the user explicitly reconnects. Other close codes keep the capped-backoff retry. The decision lives in `ReconnectPolicy.ts` (`shouldReconnectAfterClose`), kept as a pure module so it is unit-testable without DOM/CSS. -- The MCP server emits `1008` for a duplicate connection on the same user token (`PluginBridge`) and for a missing `userToken` in multi-user mode. -- A tab rejected with `1008` never reaches `connected`, so the frontend's 60s reconnect watcher (`start-reconnect-watcher` in `app.main.data.workspace.mcp`, started only on `connected`) does not engage; recovery is manual via "Connect here". +- The MCP server emits `1008` for a missing `userToken` in multi-user mode, for missing/invalid connection metadata, and to close a connection displaced by a newer one with the same user and session ID. +- Session IDs are deterministic per tab and file, so a reconnect reuses its ID while the old socket may be half-open: the newest connection wins (`PluginBridge.displaceConnection`). Across instances, each new connection publishes a session claim on a per-user Redis channel; an instance displaces its local connection only on a foreign claim that arrives after its own claim (channel order, no clocks). Discovery deduplicates by session ID. +- A tab rejected with `1008` never reaches `connected`, so the frontend's 60s reconnect watcher (`start-reconnect-watcher` in `app.main.data.workspace.mcp`, started only on `connected`) does not engage; recovery is manual via the MCP menu. diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 3f11995d2b..815a468fea 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -64,7 +64,7 @@ Watch more applications in the **[Penpot MCP video playlist](https://www.youtube There are three key pieces: * **MCP server**: a service that exposes tools to your AI client. It receives requests from the client and forwards them to Penpot. -* **MCP plugin in Penpot**: a plugin that runs inside Penpot and connects your open file to the MCP server. It is what allows the server to access the currently focused page. +* **MCP plugin in Penpot**: a plugin that runs inside Penpot and connects your open file to the MCP server. It allows the server to access that file and its pages. * **MCP client**: the tool where you write prompts (Cursor, Claude Code, Copilot-style tools, etc.). It connects to the MCP server using a server URL and an MCP key (or your active Penpot session in the current local setup). ![How the AI client, Penpot MCP server, plugin, and design file connect](/img/mcp/mcp-flow.webp) @@ -74,8 +74,23 @@ There are three key pieces: Some important concepts for users: * **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key. * **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup. -* **Currently focused page**: MCP always operates on the page you have in focus in Penpot. If you change the focused page (even in another browser window), the MCP context follows that page. -* **Active MCP tab**: MCP can only be active in one browser tab at a time. If you have Penpot open in several tabs, you choose explicitly which one owns MCP before running agents. +* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID. Several tabs can connect independently, including tabs showing the same file. +* **Current page**: the page open in the selected session. Switching browser focus to another tab does not change which session the agent uses. + +### Working with multiple files + +Connect the tabs you want to use: + +* **Remote MCP**: open each file and choose **MCP → Connect** in the toolbar. Use **Disconnect** in the same menu to disconnect that tab. +* **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. +* Connecting or disconnecting one tab does not disconnect the others. + +Choose a session for your agent: + +* With exactly one connected session, the server selects it automatically when the agent does not specify a session ID. +* With several connected sessions, the server lists them and asks the agent to have you select one. The agent then passes that session ID with its requests. +* You can also give the agent an ID directly. For remote MCP, open the toolbar's **MCP** menu and choose **Copy session ID**. For local MCP, use **Copy** beside the session ID in the plugin window. +* For example: "Use Penpot session `o37vgcqsvt` and list the pages in that file." ### Tools and capabilities @@ -98,7 +113,7 @@ Because **remote MCP** does not expose local file-system access: ### Agents can edit designs -**Be mindful:** when MCP is connected, your AI client can run **write operations** that change the currently focused Penpot page (create, rename, move, delete, restyle, etc.). To stay safe: +**Be mindful:** when MCP is connected, your AI client can run **write operations** that change the file in the selected session (create, rename, move, delete, restyle, etc.). To stay safe: * Start with **read-only** actions (inspect, list, export) to verify your setup. * Ask the agent to **describe the intended changes** before applying them. @@ -142,7 +157,7 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this See the section **Connect your MCP client** for more details on how to connect. 5. #### Open a Penpot file and connect MCP - In Penpot, open a design file and use **File → MCP Server → Connect** to connect the plugin to your current file. + In Penpot, open a design file and choose **MCP → Connect** in the toolbar. Enabling MCP does not connect a file automatically. ![Managing MCP Server from Penpot Integrations](/img/mcp/mcp-manage.webp) @@ -156,9 +171,12 @@ When this happens, MCP fails fast instead of waiting for a long task timeout: * In Chrome and Chromium-based browsers, the plugin can report when the tab is being frozen. * In Firefox, Safari, and other browsers that do not expose the same freeze event, MCP uses plugin heartbeats. If the browser stops running the plugin JavaScript, the heartbeat becomes stale and the MCP server reports that the Penpot tab appears to be suspended. -* If the browser unloads the tab completely, the plugin disconnects and MCP reports that no Penpot plugin instance is connected. +* If the browser unloads the tab completely, its plugin disconnects and that session is no longer available. Other connected sessions remain available. -To recover, open or focus the Penpot tab again, wait until MCP reconnects, and retry the prompt. +To recover: + +* If the tab was only suspended, focus it, wait until MCP reconnects, and retry the prompt. +* If the tab was unloaded or reloaded, open the file and connect again. Copy the new session ID if your agent was using the previous one. To reduce the chances of the browser putting Penpot to sleep during long MCP sessions: @@ -239,7 +257,7 @@ Note: For clients that do not support HTTP servers directly (like Claude Desktop ### Final check -In Penpot, open a file and connect the plugin from **File → MCP Server → Connect**, then run a read-only prompt first. +In Penpot, open a file and choose **MCP → Connect** in the toolbar, then run a read-only prompt first. *** @@ -253,7 +271,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted 1. Open **Your account → Integrations**. 2. In the **MCP Server** section, read the short description to confirm that feature is available for your account. -3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. +3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each file explicitly. 4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely. * Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples. 5. Once enabled, you will see: @@ -309,11 +327,11 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat 1. **Enable MCP** * Go to **Your account → Integrations → MCP Server** and set **Status** to **Enabled**. 2. **Connect plugin**: - * Open a design file and use **File → MCP Server → Connect**. + * Open a design file and choose **MCP → Connect** in the toolbar. Repeat in each tab you want the agent to access. 3. **Run prompts**: * Open your MCP client and start with read-only prompts first (`list`, `inspect`, `analyze`), then continue with write actions. -MCP always acts on the **currently focused page** in the active Penpot tab. +Requests target the selected MCP session. See [Working with multiple files](#working-with-multiple-files) to choose a session. #### Manage @@ -343,7 +361,7 @@ Security recommendations to highlight in the Help Center: * Treat your MCP key like a password or access token, do not share it in screenshots or code samples. * Regenerate the key if you suspect it may have leaked. -* Remember that disabling MCP Server or disconnecting the plugin stops agents from modifying your files, even if a client is still configured. +* Disabling MCP Server disconnects your integrated sessions. Disconnecting one tab stops access through that connection; other connected sessions remain available. *** @@ -375,7 +393,7 @@ Leave this terminal running while you use MCP. 5. Run the plugin and click **Connect to MCP server**. -6. Make sure the plugin shows **Connected** and keep the plugin window open while working with AI agents. +6. Make sure the plugin shows **Connected** and keep the plugin window open while working with AI agents. The plugin displays the session ID and a **Copy** button. > Some Chromium-based browsers may block the connection from `https://design.penpot.app` to `http://localhost`. If that happens, explicitly allow local network access or use a browser like Firefox. @@ -420,7 +438,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat Open your MCP client and start with read-only prompts first (`list`, `inspect`, `analyze`), then continue with write actions. -MCP always acts on the **currently focused page** in the active Penpot tab. +Requests target the selected MCP session. See [Working with multiple files](#working-with-multiple-files) to choose a session. #### Manage diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index bc7ef868eb..74884289c4 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -37,7 +37,8 @@ :description "This plugin enables interaction with the Penpot MCP server" :allow-background true :permissions - #{"library:read" "library:write" + #{"user:read" + "library:read" "library:write" "comment:read" "comment:write" "content:write" "content:read"}}) @@ -47,10 +48,20 @@ (defn connect-mcp [] (ptk/reify ::connect-mcp + ptk/UpdateEvent + (update [_ state] + (if (and (get-in state [:mcp :enabled]) + (get-in state [:mcp :token-valid])) + (update state :mcp assoc + :connection-requested true + :connection-status "connecting") + state)) + ptk/WatchEvent - (watch [_ _ _] - (rx/of (mbc/event :mcp/force-disconnect {}) - (ptk/data-event ::connect))))) + (watch [_ state _] + (if (get-in state [:mcp :connection-requested]) + (rx/of (ptk/data-event ::connect)) + (rx/empty))))) (defn- start-reconnect-watcher [] @@ -63,8 +74,9 @@ ;; Slow app-level fallback. The plugin owns normal WebSocket ;; reconnects; this only restarts it if the app remains in a ;; failed connection state. - (when (contains? reconnect-fallback-statuses - (-> @st/state :mcp :connection-status)) + (when (and (get-in @st/state [:mcp :connection-requested]) + (contains? reconnect-fallback-statuses + (-> @st/state :mcp :connection-status))) (.log js/console "Reconnecting to MCP...") (st/emit! (ptk/data-event ::connect)))))))) @@ -74,6 +86,8 @@ (rx/dispose! @interval-sub) (reset! interval-sub nil))) +(declare user-disconnect-mcp) + ;; This event will arrive when the mcp is enabled in the dashboard (defn update-mcp-status [value] @@ -86,37 +100,37 @@ ptk/WatchEvent (watch [_ _ _] - (case value - true (rx/of (connect-mcp)) - false (rx/of (ptk/data-event ::disconnect)) - nil)))) + (if (false? value) + (rx/of (user-disconnect-mcp)) + (rx/empty))))) (defn update-mcp-connection-status - [value] - (ptk/reify ::update-mcp-plugin-connection - ptk/UpdateEvent - (update [_ state] - (update state :mcp assoc :connection-status value)) - - ptk/WatchEvent - (watch [_ _ _] - ;; Only one MCP plugin instance may be active across browser tabs. - ;; When this tab becomes connected, tell every other tab to - ;; disconnect (which also stops their reconnect watcher). Otherwise - ;; several tabs stay connected at once and the MCP server reports - ;; "multiple instances connected" and the agent fails. - (when (= "connected" value) - (rx/of (mbc/event :mcp/force-disconnect {})))))) + ([value] + (update-mcp-connection-status value nil)) + ([value session-id] + (ptk/reify ::update-mcp-plugin-connection + ptk/UpdateEvent + (update [_ state] + (if (get-in state [:mcp :connection-requested]) + (update state :mcp assoc + :connection-status value + :session-id session-id) + state))))) ;; This event will arrive when the user selects disconnect on the menu -;; or there is a broadcast message for disconnection (defn user-disconnect-mcp [] (ptk/reify ::user-disconnect-mcp + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc + :connection-requested false + :connection-status "disconnected" + :session-id nil)) + ptk/WatchEvent (watch [_ _ _] - (rx/of (ptk/data-event ::disconnect) - (update-mcp-connection-status "disconnected"))) + (rx/of (ptk/data-event ::disconnect))) ptk/EffectEvent (effect [_ _ _] @@ -134,30 +148,43 @@ stopper-s (rx/merge (rx/filter (ptk/type? ::dw/finalize-workspace) stream) - (rx/filter (ptk/type? ::stop-mcp-plugin) stream)) + (rx/filter (ptk/type? ::stop-mcp-plugin) stream) + (rx/filter (ptk/type? ::init) stream)) + active? (atom true) extension #js {:getToken (constantly token) :getServerUrl #(str cf/mcp-ws-uri) + :isConnectionRequested #(and @active? + (get-in @st/state [:mcp :connection-requested])) :setMcpStatus - (fn [status] - (when (= status "connected") - (start-reconnect-watcher)) - (st/emit! (update-mcp-connection-status status)) - (log/info :hint "MCP STATUS" :status status)) + (fn [status session-id] + (when @active? + (when (and (= status "connected") + (get-in @st/state [:mcp :connection-requested])) + (start-reconnect-watcher)) + (st/emit! (update-mcp-connection-status status session-id)) + (log/info :hint "MCP STATUS" :status status))) :on (fn [event cb] (when-let [event - (case event - "disconnect" ::disconnect - "connect" ::connect - nil)] + (when @active? + (case event + "disconnect" ::disconnect + "connect" ::connect + nil))] (->> stream (rx/filter (ptk/type? event)) (rx/take-until stopper-s) (rx/subs! (fn [_] (cb))))))}] + (->> stopper-s + (rx/take 1) + (rx/subs! (fn [_] + (reset! active? false) + (stop-reconnect-watcher!) + (dp/close-plugin! default-manifest)))) (dp/start-plugin! manifest #js {:mcp extension}))))) (defn- stop-mcp-plugin @@ -198,7 +225,11 @@ (update [_ state] (let [profile (get state :profile) mcp-enabled? (-> profile :props :mcp-enabled boolean)] - (update state :mcp assoc :enabled mcp-enabled?))) + (update state :mcp assoc + :enabled mcp-enabled? + :connection-requested false + :connection-status "disconnected" + :session-id nil))) ptk/WatchEvent (watch [_ state stream] @@ -206,7 +237,6 @@ (rx/filter (ptk/type? ::dw/finalize-workspace) stream) (rx/filter (ptk/type? ::init) stream)) - session-id (get state :session-id) mcp-state (get state :mcp)] (->> (rx/merge @@ -229,19 +259,10 @@ (rx/map init-mcp-plugin)) (rx/empty)) - (->> mbc/stream - (rx/filter (mbc/type? :mcp/force-disconnect)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map deref) - (rx/map (fn [] (user-disconnect-mcp)))) - (->> mbc/stream (rx/filter (mbc/type? :mcp/enable)) (rx/mapcat (fn [_] - ;; Re-init so the force-disconnect - ;; listener is set up now that MCP - ;; is enabled. + ;; initialize the idle plugin now that MCP is enabled (rx/of (update-mcp-status true) (init))))) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index f3d843000d..0a10cf095d 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -12,6 +12,7 @@ [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.drawing.common :as dwdc] @@ -28,6 +29,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -298,29 +300,83 @@ (mf/defc mcp-tool* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [is-mcp-connected]}] - (let [menu-open* (mf/use-state false) + [{:keys [is-mcp-connected is-connection-requested session-id]}] + (let [copied-text (tr "workspace.toolbar.mcp-session-copied") + copy-error (tr "errors.clipboard-api-unavailable") + menu-open* (mf/use-state false) menu-open? (deref menu-open*) - on-toggle-menu + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + on-open-menu (mf/use-fn (fn [event] (dom/stop-propagation event) - (swap! menu-open* not))) + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* true))) on-close-menu (mf/use-fn - #(reset! menu-open* false)) + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* false))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! menu-open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! menu-open* false) + (mf/set-ref-val! close-timer* nil)))))) on-connect (mf/use-fn #(st/emit! (mcp/connect-mcp) (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace:toolbar"})))] + ::ev/origin "workspace:toolbar"}))) - [:* + on-disconnect + (mf/use-fn + #(st/emit! (mcp/user-disconnect-mcp))) + + on-copy-session + (mf/use-fn + (mf/deps session-id copied-text copy-error) + (fn [] + (-> (clipboard/to-clipboard session-id) + (.then #(st/emit! (ntf/info copied-text))) + (.catch #(st/emit! (ntf/error copy-error))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} [:> button* {:variant "ghost" - :on-click on-toggle-menu + :on-click on-open-menu + :aria-haspopup true + :aria-expanded menu-open? :aria-pressed menu-open? :data-tool "mcp" :data-testid "mcp-btn"} @@ -336,13 +392,22 @@ [:> dropdown-menu* {:show menu-open? :on-close on-close-menu :class (stl/css :toolbar-mcp-dropdown)} - (if is-mcp-connected + (when (or is-mcp-connected session-id) [:li {:class (stl/css :toolbar-mcp-dropdown-info) :role "presentation"} - (tr "workspace.toolbar.mcp-connected")] + (when is-mcp-connected + [:span (tr "workspace.toolbar.mcp-connected")]) + (when session-id + [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) + (when session-id [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click on-connect} - (tr "workspace.toolbar.mcp-connect-here")])]]])) + :on-click on-copy-session} + (tr "workspace.toolbar.mcp-copy-session-id")]) + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click (if is-connection-requested on-disconnect on-connect)} + (if is-connection-requested + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) (mf/defc top-toolbar* {::mf/wrap [mf/memo]} @@ -480,7 +545,9 @@ (when mcp-show? [:li {:class (stl/css :toolbar-option)} - [:> mcp-tool* {:is-mcp-connected mcp-connected?}]])] + [:> mcp-tool* {:is-mcp-connected mcp-connected? + :is-connection-requested (:connection-requested mcp) + :session-id (:session-id mcp)}]])] [:button {:title (tr "workspace.toolbar.toggle-toolbar") :aria-label (tr "workspace.toolbar.toggle-toolbar") diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 23581afc99..7f5637c8f5 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -83,7 +83,7 @@ inset-inline-start: 50%; transform: translateX(-50%); margin-block-start: var(--sp-xxs); - padding: var(--sp-xxs) 0; + padding: var(--sp-xxs); border-radius: $br-8; border: #{$b-1} solid var(--menu-border-color); background-color: var(--menu-background-color); @@ -158,21 +158,35 @@ top: $sz-36; } +// Same look as the workspace main menu (`.base-menu` in main_menu.scss). +// The toolbar overrides `--menu-background-color`, so restore the global +// menu value here. .toolbar-mcp-dropdown { - box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + --menu-background-color: var(--color-background-tertiary); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); z-index: var(--z-index-dropdown); margin: 0; padding: var(--sp-xs); - border: $b-1 solid var(--panel-border-color); + border: $b-2 solid var(--panel-border-color); border-radius: $br-8; background-color: var(--menu-background-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); } .toolbar-mcp-dropdown-info { @include t.use-typography("body-small"); + display: flex; + flex-direction: column; + gap: var(--sp-xs); padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); + font-size: px2rem(11); white-space: nowrap; } @@ -184,10 +198,12 @@ padding: var(--sp-s) var(--sp-m); border-radius: $br-8; color: var(--menu-foreground-color); + background-color: var(--menu-background-color); white-space: nowrap; &:hover { - background-color: var(--menu-background-color-hover); + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); } } diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 76b8fbd948..8bd36d07aa 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -8,9 +8,12 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] + [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.workspace.mcp :as mcp] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] [potok.v2.core :as ptk])) (t/deftest test-update-mcp-status @@ -28,12 +31,12 @@ (t/deftest test-update-mcp-connection-status (t/testing "sets connection status to connected" - (let [state {:mcp {:connection-status "disconnected"}} + (let [state {:mcp {:connection-requested true :connection-status "disconnected"}} result (ptk/update (mcp/update-mcp-connection-status "connected") state)] (t/is (= "connected" (get-in result [:mcp :connection-status]))))) (t/testing "sets connection status to disconnected" - (let [state {:mcp {:connection-status "connected"}} + (let [state {:mcp {:connection-requested true :connection-status "connected"}} result (ptk/update (mcp/update-mcp-connection-status "disconnected") state)] (t/is (= "disconnected" (get-in result [:mcp :connection-status])))))) @@ -123,3 +126,63 @@ event (du/delete-access-token {:id (uuid/next)}) result (ptk/update event state)] (t/is (= 2 (count (:access-tokens result)))))))) + +(t/deftest ^:async test-enable-does-not-connect + (let [events (atom [])] + (await (a/observe (ptk/watch (mcp/update-mcp-status true) {} (rx/empty)) + :on-next #(swap! events conj %))) + (t/is (empty? @events)))) + +(t/deftest ^:async test-connect-is-local + (let [event (mcp/connect-mcp) + state (ptk/update event {:mcp {:enabled true :token-valid true}}) + events (atom [])] + (t/is (true? (get-in state [:mcp :connection-requested]))) + (await (a/observe (ptk/watch event state (rx/empty)) + :on-next #(swap! events conj (ptk/type %)))) + (t/is (= [:app.main.data.workspace.mcp/connect] @events)))) + +(t/deftest test-disconnect-clears-connection-intent + (let [state {:mcp {:connection-requested true + :connection-status "connecting" + :session-id "pq3gxqddgj"}} + result (ptk/update (mcp/user-disconnect-mcp) state)] + (t/is (false? (get-in result [:mcp :connection-requested]))) + (t/is (= "disconnected" (get-in result [:mcp :connection-status]))) + (t/is (nil? (get-in result [:mcp :session-id]))))) + +(t/deftest test-init-clears-previous-file-connection + (let [state {:profile {:props {:mcp-enabled true}} + :mcp {:connection-requested true + :connection-status "connected" + :session-id "pq3gxqddgj"}} + result (ptk/update (mcp/init) state)] + (t/is (false? (get-in result [:mcp :connection-requested]))) + (t/is (= "disconnected" (get-in result [:mcp :connection-status]))) + (t/is (nil? (get-in result [:mcp :session-id]))))) + +(t/deftest test-late-status-cannot-reconnect + (let [state {:mcp {:connection-requested false :connection-status "disconnected"}}] + (t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state))))) + +(t/deftest test-plugin-callbacks-stop-with-workspace + (doseq [stop-event [:app.main.data.workspace/finalize-workspace + :app.main.data.workspace.mcp/init]] + (let [stream (rx/subject) + extension (atom nil) + calls (atom 0) + closed (atom 0)] + (with-redefs [dp/start-plugin! (fn [_ extensions] + (reset! extension (.-mcp extensions))) + dp/close-plugin! (fn [_] (swap! closed inc))] + (ptk/effect (#'mcp/init-mcp-plugin {:token "test-token"}) nil stream) + (.on @extension "connect" #(swap! calls inc)) + (rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect)) + (t/is (= 1 @calls)) + (rx/push! stream (ptk/data-event stop-event)) + (t/is (= 1 @closed)) + (t/is (false? (.isConnectionRequested @extension))) + (.on @extension "connect" #(swap! calls inc)) + (rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect)) + (t/is (= 1 @calls)) + (rx/end! stream))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b25cf8ed43..f51ef64459 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9218,6 +9218,18 @@ msgstr "Connect here" msgid "workspace.toolbar.mcp-connected" msgstr "MCP connected" +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-copy-session-id" +msgstr "Copy session ID" + +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-session-copied" +msgstr "Session ID copied" + +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-session-id" +msgstr "Session ID: %s" + #: src/app/main/ui/workspace/top_toolbar.cljs:65, src/app/main/ui/workspace/top_toolbar.cljs:425 msgid "workspace.toolbar.move" msgstr "Move (%s)" diff --git a/mcp/README.md b/mcp/README.md index 0e24eef43c..1661f0d019 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -143,7 +143,7 @@ This bootstrap command will: 4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default) 5. Open the plugin UI 6. In the plugin UI, click "Connect to MCP server". - The connection status should change from "Not connected" to "Connected to MCP server". + The connection status should change from "Not connected" to "Connected". (Check the browser's developer console for WebSocket connection logs. Check the MCP server terminal for WebSocket connection messages.) @@ -238,6 +238,24 @@ After updating the configuration file, restart Claude Desktop completely for the After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom of the prompt input area. +### Working with Multiple Files + +Connect the files you want to use: + +* Open each file in a separate Penpot tab, run the plugin, and connect it to the same MCP server. +* Each connection has its own short session ID, shown in the plugin UI with a **Copy** button. +* Multiple tabs can connect to the same file. Connecting or disconnecting one tab does not disconnect the others. + +Choose a session for your agent: + +* With exactly one connected session, the server selects it automatically when the agent omits the session ID. +* With several connected sessions, the server lists them and asks the agent to have you select one. +* You can also copy an ID from the plugin and include it in your prompt, for example: + "Use Penpot session `o37vgcqsvt` and list the pages in that file." + +For integrated remote MCP connection controls and session behavior, see the +[Help Center guide](../docs/mcp/index.md#working-with-multiple-files). + ## Repository Structure This repository is a monorepo containing four main components: diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index 36e52cc531..3809a14fdd 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -1,3 +1,16 @@ +/** Metadata identifying one connected Penpot plugin session. */ +export interface PenpotSession { + sessionId: string; + fileId: string; + fileName: string; +} + +/** First message sent by the plugin on each new WebSocket connection. */ +export interface PluginConnectionInit { + type: "initialize"; + session: PenpotSession; +} + /** * Result of a plugin task execution. * diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index de2ff5853c..bb525e1683 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -23,6 +23,22 @@ Disconnect MCP Server + +
void>(); + const timers = new Map void>(); + let timerId = 0; + class FakeSocket { + static OPEN = 1; + static CONNECTING = 0; + readyState = 0; + sent: any[] = []; + onopen?: () => void; + onclose?: (event: any) => void; + onmessage?: (event: any) => void; + constructor(_url: string) { + sockets.push(this); + } + open() { + this.readyState = 1; + this.onopen?.(); + } + send(raw: string) { + this.sent.push(JSON.parse(raw)); + } + close() { + this.readyState = 3; + this.onclose?.({ code: 1000 }); + } + receive(message: any) { + this.onmessage?.({ data: JSON.stringify(message) }); + } + } + const addEventListener = (name: string, handler: (event?: any) => void) => events.set(name, handler); + runInNewContext(compile("main.ts"), { + exports: {}, + require: (name: string) => { + if (name === "./style.css") return {}; + if (name === "./ReconnectPolicy") return { shouldReconnectAfterClose }; + if (name === "./SessionId") return { SessionId }; + if (name === "./SessionIdDisplay") return { SessionIdDisplay }; + throw new Error(`Unexpected import: ${name}`); + }, + console: { log() {}, error() {}, warn() {} }, + crypto: { randomUUID }, + URLSearchParams, + WebSocket: FakeSocket, + window: { location: { hash: "" }, addEventListener }, + document: { body: { dataset: {} }, getElementById: () => null, addEventListener }, + parent: { postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) }, + setTimeout: (callback: () => void) => { + timers.set(++timerId, callback); + return timerId; + }, + clearTimeout: (id: number) => timers.delete(id), + setInterval: () => ++timerId, + clearInterval: () => {}, + PENPOT_MCP_WEBSOCKET_URL: "ws://localhost:4402", + }); + const message = (data: any) => events.get("message")!({ data }); + const connect = () => { + message({ type: "start-server" }); + sockets.at(-1)!.open(); + return messages.filter((message) => message.type === "connection-metadata-request").at(-1)!.sessionId; + }; + return { messages, sockets, events, timers, message, connect }; +} + +test("sends file metadata before marking a plugin connection ready", async () => { + const ui = pluginUi(); + const sessionId = ui.connect(); + const initialization = { + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId, fileId: "file-1", fileName: "Design" }, + }; + await ui.message(initialization); + const registeredSession = ui.sockets[0].sent[0].session; + assert.equal(registeredSession.sessionId, "pq3gxqddgj"); + assert.notEqual(registeredSession.sessionId, sessionId); + assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId }); + assert.equal( + ui.messages.some((message) => message.status === "connected"), + false + ); + ui.sockets[0].receive({ type: "initialized" }); + assert.equal(ui.messages.at(-1).status, "connected"); +}); + +test("reconnects with the same session ID and ignores metadata for the old socket", async () => { + const ui = pluginUi(); + const firstId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: firstId, fileId: "file-1", fileName: "Design" }, + }); + const sessionId = ui.sockets[0].sent[0].session.sessionId; + ui.sockets[0].close(); + const reconnect = [...ui.timers.values()][0]; + reconnect(); + ui.sockets[1].open(); + const secondId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId; + assert.notEqual(firstId, secondId); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: firstId, fileId: "old", fileName: "Old file" }, + }); + assert.deepEqual(ui.sockets[1].sent, []); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: secondId, fileId: "file-1", fileName: "Design" }, + }); + assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId); + assert.notEqual(ui.sockets[1].sent[0].session.sessionId, secondId); +}); + +test("resume and freeze events cannot send liveness messages before initialization", () => { + const ui = pluginUi(); + ui.connect(); + ui.events.get("resume")!(); + ui.events.get("freeze")!(); + assert.deepEqual(ui.sockets[0].sent, []); + ui.sockets[0].receive({ type: "initialized" }); + ui.events.get("resume")!(); + assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); +}); + +test("plugin reads the current file for each connection metadata request", () => { + let onMessage!: (message: any) => void; + const messages: any[] = []; + const penpot = { + currentFile: { id: "file-1", name: "First design" }, + currentUser: { sessionId: "tab-1" }, + theme: "dark", + ui: { + open() {}, + onMessage: (handler: (message: any) => void) => { + onMessage = handler; + }, + sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))), + }, + on() {}, + }; + runInNewContext(compile("plugin.ts"), { + exports: {}, + penpot, + mcp: undefined, + require: () => ({ ExecuteCodeTaskHandler: class {} }), + }); + onMessage({ type: "connection-metadata-request", sessionId: "first" }); + penpot.currentFile = { id: "file-2", name: "Second design" }; + onMessage({ type: "connection-metadata-request", sessionId: "second" }); + assert.deepEqual(messages, [ + { + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: "first", fileId: "file-1", fileName: "First design" }, + }, + { + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: "second", fileId: "file-2", fileName: "Second design" }, + }, + ]); +}); + +test("integrated connections reuse the short ID across reconnects", async () => { + const ui = pluginUi(); + ui.message({ type: "mcp-mode", integratedRemoteMcp: true }); + const initialize = async (requestId: string) => { + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: requestId, fileId: "file-1", fileName: "Design" }, + }); + }; + await initialize(ui.connect()); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "pq3gxqddgj"); + ui.sockets[0].receive({ type: "initialized" }); + assert.equal(ui.messages.at(-1).sessionId, "pq3gxqddgj"); + ui.sockets[0].close(); + [...ui.timers.values()][0](); + ui.sockets[1].open(); + const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId; + await initialize(requestId); + assert.equal(ui.sockets[1].sent[0].session.sessionId, "pq3gxqddgj"); +}); + +test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => { + const ui = pluginUi(); + ui.message({ type: "mcp-mode", integratedRemoteMcp: true }); + const requestId = ui.connect(); + const pending = ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: requestId, fileId: "file-1", fileName: "Design" }, + }); + ui.message({ type: "stop-server" }); + await pending; + ui.sockets[0].receive({ type: "initialized" }); + assert.deepEqual(ui.sockets[0].sent, []); + assert.equal(ui.messages.at(-1).status, "disconnected"); + assert.equal(ui.timers.size, 0); +}); + +test("integrated plugin stays idle until explicitly connected, including while the UI loads", () => { + let onMessage!: (message: any) => void; + const events = new Map void>(); + const messages: any[] = []; + const statuses: string[] = []; + runInNewContext(compile("plugin.ts"), { + exports: {}, + console: { log() {} }, + PENPOT_MCP_VERSION: "0.0.0", + penpot: { + theme: "dark", + version: "0.0.0", + ui: { + open() {}, + onMessage: (handler: (message: any) => void) => { + onMessage = handler; + }, + sendMessage: (message: any) => messages.push(message), + }, + on() {}, + }, + mcp: { + getToken: () => "test-token", + getServerUrl: () => "ws://localhost:4402", + isConnectionRequested: () => false, + setMcpStatus: (status: string) => statuses.push(status), + on: (event: string, callback: () => void) => events.set(event, callback), + }, + require: () => ({ ExecuteCodeTaskHandler: class {} }), + }); + assert.ok(!statuses.includes("connecting")); + events.get("connect")!(); + events.get("disconnect")!(); + onMessage({ type: "ui-initialized" }); + assert.ok(!messages.some((message) => message.type === "start-server")); + events.get("connect")!(); + assert.equal(messages.at(-1).type, "start-server"); +}); + +test("standalone session ID survives reconnects and plugin restarts in the same app instance", async () => { + const ui = pluginUi(); + const firstRequestId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: firstRequestId, fileId: "file-1", fileName: "Design" }, + }); + const sessionId = ui.sockets[0].sent[0].session.sessionId; + ui.message({ type: "stop-server" }); + assert.equal(ui.messages.at(-1).sessionId, null); + const secondRequestId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: secondRequestId, fileId: "file-1", fileName: "Design" }, + }); + assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId); + ui.sockets[1].receive({ type: "initialized" }); + assert.equal(ui.messages.at(-1).sessionId, sessionId); + + const next = pluginUi(); + const nextRequestId = next.connect(); + await next.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: nextRequestId, fileId: "file-1", fileName: "Design" }, + }); + assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId); +}); diff --git a/mcp/packages/plugin/src/SessionId.test.ts b/mcp/packages/plugin/src/SessionId.test.ts new file mode 100644 index 0000000000..8837fc7d2b --- /dev/null +++ b/mcp/packages/plugin/src/SessionId.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SessionId } from "./SessionId.ts"; + +test("derives a stable 50-bit Base32 identifier from the Penpot user session and file", async () => { + assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj"); + assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj"); + assert.equal(await SessionId.forFile("tab-2", "file-1"), "jablgbun4p"); + assert.equal(await SessionId.forFile("tab-1", "file-2"), "7s3kow4ucx"); +}); + +test("encodes the Penpot user session and file without ambiguous boundaries", async () => { + assert.notEqual(await SessionId.forFile("ab", "c"), await SessionId.forFile("a", "bc")); +}); diff --git a/mcp/packages/plugin/src/SessionId.ts b/mcp/packages/plugin/src/SessionId.ts new file mode 100644 index 0000000000..e81f3e5a59 --- /dev/null +++ b/mcp/packages/plugin/src/SessionId.ts @@ -0,0 +1,21 @@ +/** Short routing identifier for a Penpot session. */ +export class SessionId { + private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; + + public static async forFile(penpotUserSessionId: string, fileId: string): Promise { + const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId])); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); + return SessionId.encode(digest); + } + + protected static encode(bytes: Uint8Array): string { + let result = ""; + for (let bit = 0; bit < 50; bit += 5) { + const byte = Math.floor(bit / 8); + const offset = bit % 8; + const value = ((bytes[byte] << 8) | bytes[byte + 1]) >> (11 - offset); + result += SessionId.ALPHABET[value & 31]; + } + return result; + } +} diff --git a/mcp/packages/plugin/src/SessionIdDisplay.test.ts b/mcp/packages/plugin/src/SessionIdDisplay.test.ts new file mode 100644 index 0000000000..ea58460771 --- /dev/null +++ b/mcp/packages/plugin/src/SessionIdDisplay.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SessionIdDisplay } from "./SessionIdDisplay.ts"; + +class DisplayFixture { + public readonly value = { textContent: "" }; + public readonly feedback = { textContent: "" }; + public onCopy!: () => Promise; + public readonly button = { + disabled: true, + addEventListener: (_event: string, listener: () => Promise) => { + this.onCopy = listener; + }, + }; + public readonly root = { + hidden: true, + querySelector: (selector: string) => + ({ + "#session-id-value": this.value, + "#copy-session-id-btn": this.button, + "#session-id-feedback": this.feedback, + })[selector], + }; + public readonly copied: string[] = []; + public readonly display: SessionIdDisplay; + + constructor( + writeText = async (text: string) => { + this.copied.push(text); + } + ) { + this.display = new SessionIdDisplay(this.root as unknown as HTMLElement, { writeText }); + } +} + +test("shows and copies the exact session ID, then clears it on disconnect", async () => { + const ui = new DisplayFixture(); + ui.display.setSessionId("pq3gxqddgj"); + assert.equal(ui.root.hidden, false); + assert.equal(ui.value.textContent, "pq3gxqddgj"); + await ui.onCopy(); + assert.deepEqual(ui.copied, ["pq3gxqddgj"]); + assert.equal(ui.feedback.textContent, "Copied"); + ui.display.setSessionId(null); + assert.equal(ui.root.hidden, true); + assert.equal(ui.value.textContent, ""); + assert.equal(ui.feedback.textContent, ""); + assert.equal(ui.button.disabled, true); + await ui.onCopy(); + assert.equal(ui.copied.length, 1); +}); + +test("reports clipboard denial and keeps the ID available for manual copying", async () => { + const ui = new DisplayFixture(async () => { + throw new Error("Permission denied"); + }); + ui.display.setSessionId("pq3gxqddgj"); + await ui.onCopy(); + assert.match(ui.feedback.textContent, /Could not copy/); + assert.equal(ui.value.textContent, "pq3gxqddgj"); +}); + +test("ignores late clipboard feedback after the session changes", async () => { + let finish!: () => void; + const ui = new DisplayFixture( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + ui.display.setSessionId("pq3gxqddgj"); + const pending = ui.onCopy(); + ui.display.setSessionId("jablgbun4p"); + finish(); + await pending; + assert.equal(ui.feedback.textContent, ""); +}); diff --git a/mcp/packages/plugin/src/SessionIdDisplay.ts b/mcp/packages/plugin/src/SessionIdDisplay.ts new file mode 100644 index 0000000000..561daa0086 --- /dev/null +++ b/mcp/packages/plugin/src/SessionIdDisplay.ts @@ -0,0 +1,41 @@ +/** Displays the active session ID and provides clipboard feedback. */ +export class SessionIdDisplay { + private sessionId: string | null = null; + private readonly value: HTMLElement; + private readonly button: HTMLButtonElement; + private readonly feedback: HTMLElement; + + private readonly root: HTMLElement; + private readonly clipboard: Pick; + + constructor(root: HTMLElement, clipboard: Pick) { + this.root = root; + this.clipboard = clipboard; + this.value = root.querySelector("#session-id-value")!; + this.button = root.querySelector("#copy-session-id-btn")!; + this.feedback = root.querySelector("#session-id-feedback")!; + this.button.addEventListener("click", () => this.copy()); + this.setSessionId(null); + } + + public setSessionId(sessionId: string | null): void { + this.sessionId = sessionId; + this.root.hidden = sessionId === null; + this.value.textContent = sessionId ?? ""; + this.button.disabled = sessionId === null; + this.feedback.textContent = ""; + } + + protected async copy(): Promise { + const sessionId = this.sessionId; + if (!sessionId) return; + try { + await this.clipboard.writeText(sessionId); + if (this.sessionId === sessionId) this.feedback.textContent = "Copied"; + } catch { + if (this.sessionId === sessionId) { + this.feedback.textContent = "Could not copy. Select the ID to copy it manually."; + } + } + } +} diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index a0eda651e1..1fa11567bb 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -1,7 +1,8 @@ interface McpOptions { getToken(): string; getServerUrl(): string; - setMcpStatus(status: string); + isConnectionRequested(): boolean; + setMcpStatus(status: string, sessionId?: string); on(eventType: "disconnect" | "connect", cb: () => void); } diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index f787cd9298..29e5c0a286 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,5 +1,8 @@ import "./style.css"; import { shouldReconnectAfterClose } from "./ReconnectPolicy"; +import { SessionId } from "./SessionId"; +import { SessionIdDisplay } from "./SessionIdDisplay"; +import type { PluginConnectionInit } from "../../common/src"; /** * the maximum allowed size for task responses sent back to the MCP server in the integrated remote MCP mode. @@ -17,6 +20,9 @@ document.body.dataset.theme = searchParams.get("theme") ?? "light"; // WebSocket connection to the MCP server let ws: WebSocket | null = null; +let connectionSessionId: string | null = null; +let connectionRequestId: string | null = null; +let connectionReady = false; const HEARTBEAT_INTERVAL_MS = 10_000; const RECONNECT_BASE_DELAY_MS = 1_000; @@ -46,6 +52,8 @@ const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement; const versionWarningEl = document.getElementById("version-warning") as HTMLElement; const versionWarningTextEl = document.getElementById("version-warning-text") as HTMLElement; +const sessionIdEl = document.getElementById("session-id"); +const sessionIdDisplay = sessionIdEl ? new SessionIdDisplay(sessionIdEl, navigator.clipboard) : null; /** * Updates the status pill and button visibility based on connection state. @@ -62,6 +70,7 @@ function updateConnectionStatus(code: string, label: string): void { } const isConnected = code === "connected"; + sessionIdDisplay?.setSessionId(isConnected ? connectionSessionId : null); if (connectBtn) connectBtn.hidden = isConnected; if (disconnectBtn) disconnectBtn.hidden = !isConnected; @@ -69,6 +78,7 @@ function updateConnectionStatus(code: string, label: string): void { { type: "update-connection-status", status: code, + sessionId: connectionSessionId, }, "*" ); @@ -138,7 +148,7 @@ function sendTaskResponse(response: any): void { * page JavaScript is frozen and unable to run MCP tasks. */ function sendHeartbeat(): boolean { - if (ws && ws.readyState === WebSocket.OPEN) { + if (connectionReady && ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "heartbeat" })); return true; } @@ -201,7 +211,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { lastConnectionToken = token; if (ws?.readyState === WebSocket.OPEN) { - updateConnectionStatus("connected", "Connected"); + if (connectionReady) updateConnectionStatus("connected", "Connected"); return; } if (ws?.readyState === WebSocket.CONNECTING) { @@ -217,23 +227,30 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { } ws = new WebSocket(wsUrl); + const socket = ws; + connectionRequestId = crypto.randomUUID(); + connectionSessionId = null; + connectionReady = false; updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { - cancelReconnect(); - startHeartbeat(); - setTimeout(() => { - if (ws) { - console.log("Connected to MCP server"); - updateConnectionStatus("connected", "Connected"); - } - }, 100); + if (ws !== socket) return; + parent.postMessage({ type: "connection-metadata-request", sessionId: connectionRequestId }, "*"); }; ws.onmessage = (event) => { + if (ws !== socket) return; try { console.log("Received from MCP server:", event.data); const request = JSON.parse(event.data); + if (request.type === "initialized") { + if (ws !== socket) return; + connectionReady = true; + cancelReconnect(); + startHeartbeat(); + updateConnectionStatus("connected", "Connected"); + return; + } // Track the current task received from the MCP server if (request.task) { updateCurrentTask(request.task); @@ -247,6 +264,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { }; ws.onclose = (event: CloseEvent) => { + if (ws !== socket) return; stopHeartbeat(); // keep the explicit error state if one was already shown if (!wsError) { @@ -256,11 +274,10 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { updateCurrentTask(null); } ws = null; + connectionSessionId = null; + connectionReady = false; if (!shouldReconnectAfterClose(event.code)) { - // Policy violation (e.g. duplicate connection for the same user - // token - another tab already holds the connection). Retrying - // would be refused again immediately, so stay disconnected - // until the user explicitly reconnects. + // a policy violation requires the user to correct the connection settings shouldReconnect = false; cancelReconnect(); return; @@ -287,7 +304,14 @@ function disconnectFromMcpServer(): void { shouldReconnect = false; cancelReconnect(); stopHeartbeat(); - ws?.close(); + const socket = ws; + ws = null; + connectionRequestId = null; + connectionSessionId = null; + connectionReady = false; + socket?.close(); + updateConnectionStatus("disconnected", "Disconnected"); + updateCurrentTask(null); } copyCodeBtn?.addEventListener("click", () => { @@ -309,7 +333,50 @@ disconnectBtn?.addEventListener("click", () => { }); // Listen plugin.ts messages -window.addEventListener("message", (event) => { +window.addEventListener("message", async (event) => { + if (event.data.type === "initialize") { + const initialization = event.data as PluginConnectionInit & { penpotUserSessionId?: string }; + const socket = ws; + const requestId = connectionRequestId; + if ( + !shouldReconnect || + socket?.readyState !== WebSocket.OPEN || + initialization.session.sessionId !== requestId + ) { + return; + } + try { + if (!initialization.penpotUserSessionId) { + throw new Error("Missing Penpot user session ID"); + } + const sessionId = await SessionId.forFile( + initialization.penpotUserSessionId, + initialization.session.fileId + ); + if ( + !shouldReconnect || + ws !== socket || + connectionRequestId !== requestId || + socket.readyState !== WebSocket.OPEN + ) { + return; + } + connectionSessionId = sessionId; + socket.send( + JSON.stringify({ + type: "initialize", + session: { ...initialization.session, sessionId }, + }) + ); + } catch (error) { + console.error("Failed to initialize MCP session:", error); + if (ws === socket) { + disconnectFromMcpServer(); + updateConnectionStatus("error", "Failed to initialize MCP session"); + } + } + return; + } if (event.data.type === "mcp-mode") { isIntegratedRemoteMcp = event.data.integratedRemoteMcp; } @@ -354,7 +421,7 @@ function handleTabResumed(): void { // Chrome: about to pause page JavaScript. document.addEventListener("freeze", () => { - if (ws && ws.readyState === WebSocket.OPEN) { + if (connectionReady && ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "freeze" })); } }); diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index 502116707f..eb01d2d571 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,6 +1,7 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; import { formatTaskError } from "./ErrorUtils"; +import type { PluginConnectionInit } from "../../common/src"; /** * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server @@ -19,7 +20,7 @@ function extractVersionPrefix(version: string): string { return match ? match[1] : version; } -mcp?.setMcpStatus("connecting"); +let uiInitialized = false; /** * Registry of all available task handlers. @@ -34,8 +35,11 @@ penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { } as any); // Register message handlers -penpot.ui.onMessage((message) => { +penpot.ui.onMessage< + string | { id: string; type?: string; status?: string; sessionId?: string; task: string; params: any } +>((message) => { if (typeof message === "object" && message.type === "ui-initialized") { + uiInitialized = true; // Inform the UI about the operating mode penpot.ui.sendMessage({ type: "mcp-mode", @@ -53,16 +57,26 @@ penpot.ui.onMessage { @@ -113,6 +127,7 @@ if (mcp) { }); }); mcp.on("connect", async () => { + if (!uiInitialized) return; penpot.ui.sendMessage({ type: "start-server", url: mcp?.getServerUrl(), diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css index 53e0a9da3d..a0941a5247 100644 --- a/mcp/packages/plugin/src/style.css +++ b/mcp/packages/plugin/src/style.css @@ -188,3 +188,14 @@ details[open] > .collapsible-header .collapsible-arrow { width: 100%; margin-top: var(--spacing-4); } + +.session-id-row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +#session-id-value { + flex: 1; + user-select: all; +} diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 455b2784ba..e9eaa9cc75 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -12,7 +12,7 @@ "start:multi-user": "node dist/index.js --multi-user", "start:dev": "node --import ts-node/register src/index.ts", "start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user", - "test": "tsx --test src/*.test.ts", + "test": "tsx --test \"src/**/*.test.ts\"", "test:integration:export-sema": "tsx scripts/integration-test-export-image-semaphore.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" diff --git a/mcp/packages/server/src/PenpotConnection.ts b/mcp/packages/server/src/PenpotConnection.ts new file mode 100644 index 0000000000..267c1b159b --- /dev/null +++ b/mcp/packages/server/src/PenpotConnection.ts @@ -0,0 +1,22 @@ +import type { WebSocket } from "ws"; +import type { PenpotSession } from "@penpot/mcp-common"; + +/** + * Observable liveness state of a plugin connection. + */ +export interface PluginLivenessState { + /** timestamp of the last plugin message, in ms since epoch. */ + lastHeartbeat: number; + /** whether the plugin reported a browser freeze. */ + frozen: boolean; +} + +/** One local Penpot WebSocket connection and its session metadata. */ +export interface PenpotConnection extends PluginLivenessState { + readonly socket: WebSocket; + readonly userToken: string | null; + readonly session: PenpotSession; + readonly pingInterval: NodeJS.Timeout; + /** whether initialization and dispatch subscriptions have completed. */ + ready: boolean; +} diff --git a/mcp/packages/server/src/PenpotMcpServer.http.test.ts b/mcp/packages/server/src/PenpotMcpServer.http.test.ts index e738a49a2d..2a72ed5305 100644 --- a/mcp/packages/server/src/PenpotMcpServer.http.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.http.test.ts @@ -106,6 +106,23 @@ test("isolates user tokens across overlapping tool calls with the same request I assert.equal(server.getSessionContext(), undefined); }); +test("passes an explicit Penpot session ID from the tool call to plugin dispatch", async (t) => { + t.mock.method(server.pluginBridge, "executePluginTask", async (_task: unknown, sessionId?: string) => ({ + data: sessionId, + })); + const response = await modernRequest( + "tools/call", + { + name: "execute_code", + arguments: { code: "return penpot.currentFile.id;", sessionId: "chosen-tab" }, + }, + "?userToken=alice" + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.match(JSON.stringify(body), /chosen-tab/); +}); + test("supports older Streamable HTTP clients without allocating a session", async () => { const client = new Client({ name: "legacy-test", version: "1" }); const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`)); diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts new file mode 100644 index 0000000000..1472353588 --- /dev/null +++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { afterEach, beforeEach, test } from "node:test"; +import { WebSocket } from "ws"; +import { PluginBridge } from "./PluginBridge"; +import { PluginTask } from "./PluginTask"; +import type { PenpotMcpServer } from "./PenpotMcpServer"; + +let bridge: PluginBridge; +let port = 17_600; +let userToken: string | undefined; +let multiUser: boolean; +let sockets: WebSocket[]; + +beforeEach(() => { + port++; + userToken = "alice"; + multiUser = true; + sockets = []; + bridge = new PluginBridge( + { + host: "127.0.0.1", + isMultiUserMode: () => multiUser, + getSessionContext: () => ({ userToken }), + } as PenpotMcpServer, + port, + 1 + ); +}); + +afterEach(async () => { + for (const socket of sockets) socket.terminate(); + await bridge.close(); +}); + +async function connect(sessionId: string, token = "alice", fileName = "Design", reply = sessionId) { + const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=${token}`); + sockets.push(socket); + await once(socket, "open"); + const initialized = once(socket, "message", { signal: AbortSignal.timeout(1500) }); + socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file-1", fileName } })); + const [message] = await initialized; + assert.equal(JSON.parse(message.toString()).type, "initialized"); + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); + }); + return socket; +} + +test("keeps two tabs for the same file and dispatches to the selected session", async () => { + await connect("tab-1"); + await connect("tab-2"); + const result = await bridge.executePluginTask(new PluginTask("test", {}), "tab-2"); + assert.equal(result.data, "tab-2"); +}); + +test("requires user selection when more than one session is connected", async () => { + await connect("tab-1", "alice", "First design"); + await connect("tab-2", "alice", "Second design"); + await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), (error: Error) => { + assert.match(error.message, /Ask the user/); + assert.match(error.message, /tab-1.*First design/); + assert.match(error.message, /tab-2.*Second design/); + return true; + }); +}); + +test("implicitly selects the only session belonging to the requesting user", async () => { + await connect("alice-tab"); + await connect("bob-tab", "bob"); + const result = await bridge.executePluginTask(new PluginTask("test", {})); + assert.equal(result.data, "alice-tab"); +}); + +test("does not dispatch to another user's session", async () => { + await connect("alice-tab"); + await connect("bob-tab", "bob"); + await assert.rejects(bridge.executePluginTask(new PluginTask("test", {}), "bob-tab"), /not connected/); +}); + +test("reports no sessions when no plugin has initialized", async () => { + await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /No Penpot.*connected/); +}); + +test("regular plugins without user tokens can select among multiple sessions", async () => { + multiUser = false; + await connect("first", ""); + await connect("second", ""); + await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /Ask the user/); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "second")).data, "second"); +}); + +test("disconnecting one session leaves its sibling available for implicit selection", async () => { + const first = await connect("first"); + await connect("second"); + const closed = once(first, "close"); + first.close(); + await closed; + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}))).data, "second"); + await assert.rejects(bridge.executePluginTask(new PluginTask("test", {}), "first"), /not connected/); +}); + +test("a reconnect with the same session ID replaces the previous connection", async () => { + const previous = await connect("tab", "alice", "Design", "previous"); + const closed = once(previous, "close"); + await connect("tab", "alice", "Design", "current"); + const [code] = await closed; + assert.equal(code, 1008); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}))).data, "current"); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "current"); +}); + +test("a half-open previous connection does not block a reconnect with the same session ID", async () => { + const previous = await connect("tab", "alice", "Design", "previous"); + // stop answering, as a socket whose network path is gone would + previous.pause(); + await connect("tab", "alice", "Design", "current"); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "current"); +}); + +test("replacing a session does not affect another user's session with the same ID", async () => { + await connect("tab", "bob", "Design", "bob"); + await connect("tab", "alice", "Design", "alice"); + userToken = "bob"; + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "bob"); +}); + +test("uninitialized sockets are not candidates for discovery", async () => { + await connect("ready"); + const pending = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(pending); + await once(pending, "open"); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}))).data, "ready"); +}); + +test("invalid initialization metadata cannot register a session", async () => { + const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(socket); + await once(socket, "open"); + const closed = once(socket, "close"); + socket.send(JSON.stringify({ type: "initialize", session: { sessionId: "bad", fileId: 42 } })); + assert.equal((await closed)[0], 1008); + await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /No Penpot sessions/); +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 305f5dc35e..f53ed3f849 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -1,13 +1,19 @@ import { WebSocket, WebSocketServer } from "ws"; import * as http from "http"; +import { z } from "zod"; import { AbstractPluginTask, PluginTask } from "./PluginTask"; -import { RemotePluginTask } from "./RemotePluginTask"; -import { PluginTaskRequest, PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; +import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; import type { RedisBridge } from "./RedisBridge"; +import type { PenpotConnection, PluginLivenessState } from "./PenpotConnection"; +import { UserPenpotConnections } from "./UserPenpotConnections"; +import type { TaskDispatcher, TaskDispatchHost } from "./dispatch/TaskDispatcher"; +import { SingleInstanceTaskDispatcher } from "./dispatch/SingleInstanceTaskDispatcher"; +import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatcher"; const KEEP_ALIVE_TIME = 30000; // 30 seconds +const INITIALIZATION_TIMEOUT_MS = 10_000; /** * Maximum plugin heartbeat age before a connection is stale. @@ -17,21 +23,14 @@ const KEEP_ALIVE_TIME = 30000; // 30 seconds */ export const HEARTBEAT_STALE_THRESHOLD_MS = 30000; -/** - * Observable liveness state of a plugin connection. - */ -export interface PluginLivenessState { - /** timestamp of the last plugin message, in ms since epoch. */ - lastHeartbeat: number; - /** whether the plugin reported a browser freeze. */ - frozen: boolean; -} - -interface ClientConnection extends PluginLivenessState { - socket: WebSocket; - userToken: string | null; - pingInterval: NodeJS.Timeout; -} +const connectionInitSchema = z.object({ + type: z.literal("initialize"), + session: z.object({ + sessionId: z.string().min(1).max(128), + fileId: z.string().min(1).max(128), + fileName: z.string(), + }), +}); /** * Throws if the plugin tab cannot currently run tasks. @@ -65,16 +64,19 @@ export function assertPluginResponsive( * Manages WebSocket connections to Penpot plugin instances and handles plugin tasks * over these connections. */ -export class PluginBridge { +export class PluginBridge implements TaskDispatchHost { public static readonly MULTIUSER_CONNECTION_ERROR_MESSAGE = `No Penpot instance connected for user token. Please ensure that Penpot is connected and that the MCP client connection is using the correct token.`; private readonly logger = createLogger("PluginBridge"); private readonly wsServer: WebSocketServer; - private readonly connectedClients: Map = new Map(); - private readonly clientsByToken: Map = new Map(); + private readonly connectionsBySocket: Map = new Map(); + private readonly userPenpotConnectionsByToken: Map = new Map(); + private readonly singleUserConnections = new UserPenpotConnections(); private readonly pendingTasks: Map> = new Map(); private readonly taskTimeouts: Map = new Map(); + private readonly taskConnections: Map = new Map(); + private readonly dispatcher: TaskDispatcher; /** * Creates the plugin bridge and starts its WebSocket server. @@ -94,6 +96,9 @@ export class PluginBridge { private readonly taskTimeoutSecs: number, private readonly redisBridge?: RedisBridge ) { + this.dispatcher = redisBridge + ? new MultiInstanceTaskDispatcher(this, redisBridge) + : new SingleInstanceTaskDispatcher(this); this.wsServer = new WebSocketServer({ port: port, host: mcpServer.host }); this.setupWebSocketHandlers(); } @@ -123,50 +128,42 @@ export class PluginBridge { this.logger.info("New WebSocket connection established"); } - // start the per-connection keep-alive ping interval - const pingInterval = setInterval(() => { - ws.ping(); - }, KEEP_ALIVE_TIME); - - // register the client connection with both indexes - const connection: ClientConnection = { - socket: ws, - userToken, - pingInterval, - lastHeartbeat: Date.now(), - frozen: false, - }; - this.connectedClients.set(ws, connection); - if (userToken) { - // ensure only one connection per userToken - if (this.clientsByToken.has(userToken)) { - this.logger.warn("Duplicate connection for given user token; rejecting new connection"); - this.removeConnection(ws); - ws.close(1008, "Duplicate connection for given user token; close previous connection first."); - return; - } - - this.clientsByToken.set(userToken, connection); - - // In multi-instance mode, subscribe to this token's Redis request channel so - // that task requests issued by other instances are dispatched to this plugin. - if (this.redisBridge) { - const tokenForSubscription = userToken; - this.redisBridge - .subscribeToTasks(userToken, (request) => - this.dispatchForwardedTask(tokenForSubscription, request) - ) - .catch((error) => this.logger.error(error, "Failed to subscribe to Redis task channel")); - } - } - + // require metadata before registering a connection for dispatch + const initializationTimeout = setTimeout(() => { + ws.close(1008, "Connection initialization timed out; please update the Penpot MCP plugin."); + }, INITIALIZATION_TIMEOUT_MS); + let initializing = false; ws.on("message", (data: Buffer) => { this.logger.debug("Received WebSocket message: %s", data.toString()); try { + const message = JSON.parse(data.toString()); + const connection = this.connectionsBySocket.get(ws); + if (!connection) { + if (initializing) { + return; + } + const initialization = connectionInitSchema.safeParse(message); + if (!initialization.success) { + ws.close(1008, "Expected connection metadata; please update the Penpot MCP plugin."); + return; + } + initializing = true; + void this.initializeConnection( + ws, + this.mcpServer.isMultiUserMode() ? userToken : null, + initialization.data.session + ) + .then(() => clearTimeout(initializationTimeout)) + .catch((error) => { + this.logger.error(error, "Failed to initialize Penpot connection"); + clearTimeout(initializationTimeout); + this.removeConnection(ws); + ws.close(1011, "Failed to initialize Penpot connection; please retry."); + }); + return; + } // any plugin message proves the page event loop is running connection.lastHeartbeat = Date.now(); - - const message = JSON.parse(data.toString()); if (message?.type === "freeze") { connection.frozen = true; this.logger.info("Plugin tab reported it is being frozen by the browser"); @@ -176,7 +173,7 @@ export class PluginBridge { if (message?.type === "heartbeat") { return; } - this.handlePluginTaskResponse(message as PluginTaskResponse); + this.handlePluginTaskResponse(message as PluginTaskResponse, connection); } catch (error) { this.logger.error(error, "Failure while processing WebSocket message"); } @@ -184,50 +181,100 @@ export class PluginBridge { ws.on("close", () => { this.logger.info("WebSocket connection closed"); + clearTimeout(initializationTimeout); this.removeConnection(ws); }); ws.on("error", (error) => { this.logger.error(error, "WebSocket connection error"); + clearTimeout(initializationTimeout); this.removeConnection(ws); + ws.terminate(); }); }); this.logger.info("WebSocket mcpServer started on port %d", this.port); } + private async initializeConnection( + ws: WebSocket, + userToken: string | null, + session: PenpotConnection["session"] + ): Promise { + let connections = this.getUserConnections(userToken); + if (!connections) { + connections = new UserPenpotConnections(); + this.userPenpotConnectionsByToken.set(userToken!, connections); + } + // start the per-connection keep-alive ping interval + const pingInterval = setInterval(() => { + ws.ping(); + }, KEEP_ALIVE_TIME); + + // register the client connection with both indexes + const connection: PenpotConnection = { + socket: ws, + userToken, + pingInterval, + session, + ready: false, + lastHeartbeat: Date.now(), + frozen: false, + }; + // the same tab reconnecting reuses its session ID while its previous socket may still be half-open + const replaced = connections.add(connection); + this.connectionsBySocket.set(ws, connection); + if (replaced) { + this.displaceConnection(replaced); + } + await this.dispatcher.onNewConnection(connection); + // disconnect may have removed this connection while subscriptions were pending + if (ws.readyState === WebSocket.OPEN && this.connectionsBySocket.get(ws) === connection) { + connection.ready = true; + ws.send(JSON.stringify({ type: "initialized" })); + } + } + + /** + * Closes a connection superseded by a newer connection for the same session. + * + * Uses close code 1008 so that a still-running plugin behind the old socket stops + * reconnecting instead of competing with the newer connection for the session. + * + * @param connection - The superseded connection + */ + public displaceConnection(connection: PenpotConnection): void { + this.logger.info("Closing connection replaced by a newer connection for the same session"); + this.removeConnection(connection.socket); + connection.socket.close(1008, "Replaced by a newer connection for this Penpot session."); + } + /** * Removes a client connection and releases all resources associated with it. * * Clears the per-connection keep-alive interval and removes the connection from the - * socket-keyed index. The token-keyed index entry (and, in multi-instance mode, the - * token's Redis task subscription) is removed only if it is owned by the given - * connection. Safe to call with a socket that is not (or no longer) registered. + * socket-keyed index and the user's session collection. The token-keyed index entry + * is removed only when the user's last local connection closes. Safe to call with a + * socket that is not (or no longer) registered. * * @param ws - The WebSocket whose connection state should be removed */ private removeConnection(ws: WebSocket): void { - const connection = this.connectedClients.get(ws); + const connection = this.connectionsBySocket.get(ws); if (!connection) { return; } + connection.ready = false; clearInterval(connection.pingInterval); - this.connectedClients.delete(ws); - if (connection.userToken) { - // Perform the token-keyed cleanup only if this connection owns the token registration. - // A connection rejected as a duplicate carries the same token but must not remove token associations. - if (this.clientsByToken.get(connection.userToken) !== connection) { - this.logger.debug("Removed connection does not own its token registration; skipping token cleanup"); - } else { - this.clientsByToken.delete(connection.userToken); - - if (this.redisBridge) { - this.redisBridge - .unsubscribeFromTasks(connection.userToken) - .catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel")); - } - } + this.connectionsBySocket.delete(ws); + const connections = this.getUserConnections(connection.userToken)!; + connections.remove(connection); + if (connection.userToken !== null && connections.size === 0) { + this.userPenpotConnectionsByToken.delete(connection.userToken); } + void this.dispatcher + .onConnectionClosed(connection) + .catch((error) => this.logger.error(error, "Failed to remove connection subscriptions")); } /** @@ -237,14 +284,19 @@ export class PluginBridge { * based on the execution result. * * @param response - The plugin task response containing ID and result + * @param connection - The responding local connection, or undefined for Redis responses */ - private handlePluginTaskResponse(response: PluginTaskResponse): void { + private handlePluginTaskResponse(response: PluginTaskResponse, connection?: PenpotConnection): void { const task = this.pendingTasks.get(response.id); if (!task) { this.logger.info(`Received response for unknown task ID: ${response.id}`); return; } + if (this.taskConnections.get(response.id) !== connection) { + return; + } + // Clear the timeout and remove the task from pending tasks const timeoutHandle = this.taskTimeouts.get(response.id); if (timeoutHandle) { @@ -252,6 +304,7 @@ export class PluginBridge { this.taskTimeouts.delete(response.id); } this.pendingTasks.delete(response.id); + this.taskConnections.delete(response.id); // Resolve or reject the task's promise based on the result if (response.success) { @@ -287,52 +340,15 @@ export class PluginBridge { this.taskTimeouts.delete(taskId); } this.pendingTasks.delete(taskId); + this.taskConnections.delete(taskId); pendingTask.rejectWithError(error); this.logger.info(`Task ${taskId} rejected: ${error.message}`); return true; } - /** - * Determines the client connection to use for executing a task. - * - * In single-user mode, returns the single connected client. - * In multi-user mode, returns the client matching the session's userToken. - * - * @returns The client connection to use - * @throws Error if no suitable connection is found or if configuration is invalid - */ - private getClientConnection(): ClientConnection { - if (this.mcpServer.isMultiUserMode()) { - const sessionContext = this.mcpServer.getSessionContext(); - if (!sessionContext?.userToken) { - throw new Error("No userToken found in session context. Multi-user mode requires authentication."); - } - - const connection = this.clientsByToken.get(sessionContext.userToken); - if (!connection) { - throw new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE); - } - - return connection; - } else { - // single-user mode: return the single connected client - if (this.connectedClients.size === 0) { - throw new Error( - `No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.` - ); - } - if (this.connectedClients.size > 1) { - throw new Error( - `Multiple (${this.connectedClients.size}) Penpot MCP Plugin instances are connected. ` + - `Ask the user to ensure that only one instance is connected at a time.` - ); - } - - // return the first (and only) connection - const connection = this.connectedClients.values().next().value; - return connection; - } + getUserConnections(userToken: string | null): UserPenpotConnections | undefined { + return userToken === null ? this.singleUserConnections : this.userPenpotConnectionsByToken.get(userToken); } /** @@ -341,18 +357,32 @@ export class PluginBridge { * and awaiting the result. * * @param task - The plugin task to execute + * @param sessionId - The target session; when omitted, discovery must yield exactly one session * @throws Error if no plugin instances are connected or available */ public async executePluginTask>( - task: PluginTask + task: PluginTask, + sessionId?: string ): Promise { - this.sendPluginTask(task, this.redisBridge !== undefined); - return await task.getResultPromise(); + let userToken: string | null = null; + if (this.mcpServer.isMultiUserMode()) { + const sessionContext = this.mcpServer.getSessionContext(); + if (!sessionContext?.userToken) { + throw new Error("No userToken found in session context. Multi-user mode requires authentication."); + } + userToken = sessionContext.userToken; + } + // attach the result handler before dispatch can reject the task + const result = task.getResultPromise(); + void this.dispatcher.dispatch(task, userToken, sessionId).catch((error) => { + task.rejectWithError(error instanceof Error ? error : new Error(String(error))); + }); + return await result; } /** - * Registers a task for response correlation, sends its request over the appropriate - * transport, and arms a timeout that rejects the task if no response is received. + * Registers a task for response correlation, sends its request over Redis, + * and arms a timeout that rejects the task if no response is received. * * The response (whether arriving over the local WebSocket or over Redis) is later * matched by ID in {@link handlePluginTaskResponse}, which settles the task via its @@ -361,60 +391,60 @@ export class PluginBridge { * * When routing via Redis, the task is rejected immediately (rather than timing out) * if the published request reached no instance, i.e. if no instance holds a plugin - * connection for the session's user token, or if publishing fails outright. + * connection for the user's session, or if publishing fails outright. * * @param task - The task to dispatch - * @param useRedis - Whether to route the request via Redis (multi-instance) rather - * than directly over the local WebSocket connection - * @param connection - The connection to use for a local (non-remote) dispatch; when - * omitted, the session's connection is resolved via {@link getClientConnection}. - * Ignored when `useRedis` is true. - * @throws Error if a local dispatch is required but no suitable connection is available + * @param userToken - The user token identifying the target session's owner + * @param sessionId - The target Penpot session */ - private sendPluginTask(task: AbstractPluginTask, useRedis: boolean, connection?: ClientConnection): void { - let onTimeout: (() => void) | undefined; + public sendRemoteTask(task: AbstractPluginTask, userToken: string, sessionId: string): void { + const redisBridge = this.redisBridge!; + this.logger.debug("Dispatching task %s via Redis", task.id); - if (useRedis) { - const sessionContext = this.mcpServer.getSessionContext(); - if (!sessionContext?.userToken) { - throw new Error("No userToken found in session context. Multi-user mode requires authentication."); - } - const userToken = sessionContext.userToken; - const redisBridge = this.redisBridge!; - this.logger.debug("Dispatching task %s via Redis", task.id); + // register the task for result correlation, then publish the request via Redis + this.pendingTasks.set(task.id, task); + void redisBridge + .sendTaskRequest(userToken, sessionId, task.toRequest(), (response) => + this.handlePluginTaskResponse(response) + ) + .then((receiverCount) => { + // fail fast when no instance received the request (no connection with matching user token and session ID in any instance) + if (receiverCount === 0) { + this.rejectPendingTask( + task.id, + new Error(`Penpot session ${JSON.stringify(sessionId)} is not connected for this user.`) + ); + } + }) + .catch((error) => { + this.rejectPendingTask(task.id, error instanceof Error ? error : new Error(String(error))); + }); - // register the task for result correlation, then publish the request via Redis - this.pendingTasks.set(task.id, task); - void redisBridge - .sendTaskRequest(userToken, task.toRequest(), (response) => this.handlePluginTaskResponse(response)) - .then((receiverCount) => { - // fail fast when no instance received the request (no connection with matching user token in any instance) - if (receiverCount === 0) { - this.rejectPendingTask(task.id, new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE)); - } - }) - .catch((error) => { - this.rejectPendingTask(task.id, error instanceof Error ? error : new Error(String(error))); - }); + // on timeout, release the response-channel subscription, since no response + // will arrive to trigger its self-unsubscribe. + this.startTaskTimeout(task, () => void redisBridge.unsubscribeFromResponse(task.id)); + } - // on timeout, release the response-channel subscription, since no response - // will arrive to trigger its self-unsubscribe. - onTimeout = () => void redisBridge.unsubscribeFromResponse(task.id); - } else { - const target = connection ?? this.getClientConnection(); - if (target.socket.readyState !== 1) { - // WebSocket is not open - throw new Error(`Plugin instance is disconnected. Task could not be sent.`); - } - - // the socket can be open while browser-throttled plugin JS cannot run tasks - assertPluginResponsive(target, Date.now()); - - // register the task for result correlation, then send over the socket - this.pendingTasks.set(task.id, task); - target.socket.send(JSON.stringify(task.toRequest())); + /** Sends a task directly to a ready local connection, with execution tracking. */ + public sendLocalTask(task: AbstractPluginTask, connection: PenpotConnection): void { + const target = connection; + if (!target.ready || target.socket.readyState !== 1) { + // WebSocket is not open + throw new Error(`Plugin instance is disconnected. Task could not be sent.`); } + // the socket can be open while browser-throttled plugin JS cannot run tasks + assertPluginResponsive(target, Date.now()); + + // register the task for result correlation, then send over the socket + this.pendingTasks.set(task.id, task); + this.taskConnections.set(task.id, target); + target.socket.send(JSON.stringify(task.toRequest())); + this.startTaskTimeout(task); + } + + /** Arms the shared execution timeout for a locally or remotely dispatched task. */ + private startTaskTimeout(task: AbstractPluginTask, onTimeout?: () => void): void { // Set up a timeout to reject the task if no response is received const timeoutHandle = setTimeout(() => { if ( @@ -431,47 +461,19 @@ export class PluginBridge { this.logger.info(`Sent task ${task.id}`); } - /** - * Dispatches a task request received over Redis to the locally-connected plugin. - * - * Invoked on the instance subscribed to a user token's request channel when another - * instance (or this one) issues a task request. A {@link RemotePluginTask} is created - * so that, once the plugin responds, the outcome is published back to the issuing - * instance's Redis response channel via the standard response-handling path. - * - * On failure to dispatch (e.g. the plugin is not connected here), an error response - * is published immediately so the requester need not wait for its timeout. - * - * @param userToken - The user token on whose request channel the request arrived; - * identifies the locally-connected plugin to dispatch to - * @param request - The serialized task request, passed through from Redis - */ - private dispatchForwardedTask(userToken: string, request: PluginTaskRequest): void { - if (!this.redisBridge) { - return; - } - - // The response is published on the channel keyed by the original request ID. - const task = new RemotePluginTask(request.task, request.params, this.redisBridge, request.id); - this.logger.debug("Dispatching remote task %s as %s to Penpot via WebSocket", request.id, task.id); - - const connection = this.clientsByToken.get(userToken); - if (!connection) { - task.rejectWithError(new Error("Plugin not connected on the receiving instance")); - return; - } - - try { - this.sendPluginTask(task, false, connection); - } catch (error) { - task.rejectWithError(error instanceof Error ? error : new Error(String(error))); - } - } - /** * Closes the WebSocket server and all connected client sockets. + * Also releases task tracking and dispatch subscriptions. */ public async close(): Promise { + for (const socket of this.wsServer.clients) { + this.removeConnection(socket); + socket.terminate(); + } + for (const taskId of this.pendingTasks.keys()) { + this.rejectPendingTask(taskId, new Error("MCP server is shutting down.")); + } + await this.dispatcher.close(); return new Promise((resolve) => { this.wsServer.close(() => { this.logger.info("WebSocket server closed"); diff --git a/mcp/packages/server/src/PluginTask.ts b/mcp/packages/server/src/PluginTask.ts index 0e0d327564..30a88c73de 100644 --- a/mcp/packages/server/src/PluginTask.ts +++ b/mcp/packages/server/src/PluginTask.ts @@ -10,7 +10,7 @@ import { randomUUID } from "crypto"; /** * Abstract base for plugin tasks, defining the parts that the plugin dispatch and - * response-correlation machinery (`PluginBridge.sendPluginTask` / + * response-correlation machinery (`PluginBridge.sendLocalTask` / * `PluginBridge.handlePluginTaskResponse`) depend upon. * * The dispatch path only needs to serialize a task to a request and, upon receiving diff --git a/mcp/packages/server/src/RedisBridge.test.ts b/mcp/packages/server/src/RedisBridge.test.ts new file mode 100644 index 0000000000..52080fcc3c --- /dev/null +++ b/mcp/packages/server/src/RedisBridge.test.ts @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { beforeEach, test, type TestContext } from "node:test"; +import Redis, { type Command } from "ioredis"; +import { WebSocket } from "ws"; +import { RedisBridge } from "./RedisBridge"; +import { PluginBridge } from "./PluginBridge"; +import { PluginTask } from "./PluginTask"; +import type { PenpotMcpServer } from "./PenpotMcpServer"; + +// Replace only the Redis command boundary; exercise the real transport, +// dispatchers, task tracking, and WebSockets above it. +let subscriptions: Map>; +let failDiscoverySubscription: boolean; +beforeEach((context) => { + // These hooks run for tests, so mocks live through each test's cleanup hooks. + const { mock } = context as TestContext; + subscriptions = new Map(); + failDiscoverySubscription = false; + mock.method(Redis.prototype, "connect", async function (this: Redis) { + this.status = "ready"; + }); + mock.method(Redis.prototype, "sendCommand", async function (this: Redis, command: Command) { + const [channel, message] = command.args.map(String); + if (command.name === "subscribe") { + const subscribers = subscriptions.get(channel) ?? new Set(); + subscribers.add(this); + subscriptions.set(channel, subscribers); + if (failDiscoverySubscription && channel.includes(".discovery.req.")) { + failDiscoverySubscription = false; + throw new Error("Subscription failed"); + } + return subscribers.size; + } + if (command.name === "unsubscribe") { + subscriptions.get(channel)?.delete(this); + return 0; + } + if (command.name === "publish") { + const subscribers = [...(subscriptions.get(channel) ?? [])]; + for (const subscriber of subscribers) subscriber.emit("message", channel, message); + return subscribers.length; + } + if (command.name === "quit") { + for (const subscribers of subscriptions.values()) subscribers.delete(this); + return "OK"; + } + throw new Error(`Unexpected Redis command: ${command.name}`); + }); +}); + +function transport(t: TestContext, tenant = "test") { + const bridge = new RedisBridge("redis://localhost:6379", tenant); + t.after(() => bridge.close()); + return bridge; +} + +test("Redis task channels isolate users, sessions, and tenants without delimiter collisions", async (t) => { + const owner = transport(t); + const caller = transport(t); + const otherTenant = transport(t, "other"); + const delivered: unknown[] = []; + await owner.subscribeToTasks("alice.one", "two", (request) => { + owner.publishTaskResponse(request.id, { id: request.id, success: true, data: "selected" }); + }); + for (const [token, session] of [ + ["alice", "one.two"], + ["bob", "two"], + ["alice.one", "other"], + ]) { + assert.equal( + await caller.sendTaskRequest(token, session, { id: "missing", task: "test", params: {} }, () => {}), + 0 + ); + } + assert.equal( + await otherTenant.sendTaskRequest( + "alice.one", + "two", + { id: "other-tenant", task: "test", params: {} }, + () => {} + ), + 0 + ); + assert.equal( + await caller.sendTaskRequest("alice.one", "two", { id: "selected", task: "test", params: {} }, (response) => + delivered.push(response.data) + ), + 1 + ); + assert.deepEqual(delivered, ["selected"]); +}); + +test("Redis discovery receives one reply per subscribed instance and returns publish's recipient count", async (t) => { + const first = transport(t); + const second = transport(t); + const requester = transport(t); + await first.subscribeToDiscovery("alice", ({ id }) => { + first.publishDiscoveryResponse(id, { + instanceId: "first", + sessions: [{ sessionId: "one", fileId: "file", fileName: "Design" }], + }); + }); + await second.subscribeToDiscovery("alice", ({ id }) => { + second.publishDiscoveryResponse(id, { instanceId: "second", sessions: [] }); + }); + const responders: string[] = []; + const count = await requester.sendDiscoveryRequest("alice", "query", (response) => + responders.push(response.instanceId) + ); + assert.equal(count, 2); + assert.deepEqual(responders.sort(), ["first", "second"]); + await requester.unsubscribeFromDiscoveryResponses("query"); + first.publishDiscoveryResponse("query", { instanceId: "late", sessions: [] }); + assert.deepEqual(responders.sort(), ["first", "second"]); +}); + +test("unsubscribing a session retains the user's other task channels", async (t) => { + const owner = transport(t); + const caller = transport(t); + await owner.subscribeToTasks("alice", "first", () => {}); + await owner.subscribeToTasks("alice", "second", () => {}); + await owner.unsubscribeFromTasks("alice", "first"); + assert.equal( + await caller.sendTaskRequest("alice", "first", { id: "first", task: "test", params: {} }, () => {}), + 0 + ); + assert.equal( + await caller.sendTaskRequest("alice", "second", { id: "second", task: "test", params: {} }, () => {}), + 1 + ); + await caller.unsubscribeFromResponse("second"); +}); + +test("a failed discovery subscription is cleaned up and can be retried", async (t) => { + const owner = transport(t); + const caller = transport(t); + failDiscoverySubscription = true; + await assert.rejects( + owner.subscribeToDiscovery("alice", () => {}), + /Subscription failed/ + ); + assert.equal(await caller.sendDiscoveryRequest("alice", "after-failure", () => {}), 0); + await caller.unsubscribeFromDiscoveryResponses("after-failure"); + await owner.subscribeToDiscovery("alice", ({ id }) => { + owner.publishDiscoveryResponse(id, { instanceId: "owner", sessions: [] }); + }); + assert.equal(await caller.sendDiscoveryRequest("alice", "retry", () => {}), 1); + await caller.unsubscribeFromDiscoveryResponses("retry"); +}); + +let port = 18_700; +function instance(t: TestContext) { + const redis = new RedisBridge("redis://localhost:6379", "test"); + const wsPort = ++port; + const bridge = new PluginBridge( + { + host: "127.0.0.1", + isMultiUserMode: () => true, + getSessionContext: () => ({ userToken: "alice" }), + } as PenpotMcpServer, + wsPort, + 0.1, + redis + ); + const sockets: WebSocket[] = []; + t.after(async () => { + for (const socket of sockets) socket.terminate(); + await bridge.close(); + await redis.close(); + }); + const connect = async ( + sessionId: string, + outcome: "success" | "error" | "silent" = "success", + reply: string = sessionId + ) => { + const socket = new WebSocket(`ws://127.0.0.1:${wsPort}?userToken=alice`); + sockets.push(socket); + await once(socket, "open"); + const initialized = once(socket, "message", { signal: AbortSignal.timeout(2000) }); + socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file", fileName: "Design" } })); + await initialized; + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + if (outcome !== "silent") { + socket.send( + JSON.stringify({ + id: request.id, + success: outcome === "success", + data: reply, + error: "Plugin execution failed", + }) + ); + } + }); + return socket; + }; + return { bridge, connect }; +} + +test("full Redis forwarding resolves the selected WebSocket's result", async (t) => { + const owner = instance(t); + const requester = instance(t); + await owner.connect("first"); + await owner.connect("second"); + assert.equal((await requester.bridge.executePluginTask(new PluginTask("test", {}), "second")).data, "second"); + assert.equal((await owner.bridge.executePluginTask(new PluginTask("test", {}), "first")).data, "first"); +}); + +test("full Redis discovery implicitly selects a sole remote WebSocket", async (t) => { + const owner = instance(t); + const requester = instance(t); + await owner.connect("only"); + assert.equal((await requester.bridge.executePluginTask(new PluginTask("test", {}))).data, "only"); +}); + +test("forwarded plugin failures retain the regular execution failure route", async (t) => { + const owner = instance(t); + const requester = instance(t); + await owner.connect("failing", "error"); + await assert.rejects( + requester.bridge.executePluginTask(new PluginTask("test", {}), "failing"), + /Plugin execution failed/ + ); +}); + +test("forwarded task timeouts release their Redis response subscriptions", async (t) => { + const owner = instance(t); + const requester = instance(t); + await owner.connect("silent", "silent"); + await assert.rejects(requester.bridge.executePluginTask(new PluginTask("test", {}), "silent"), /timed out/); + const responseSubscriptions = [...subscriptions].filter(([channel]) => channel.includes(".task.res.")); + assert.ok(responseSubscriptions.every(([, subscribers]) => subscribers.size === 0)); +}); + +test("a reconnect on another instance closes the previous instance's connection for the session", async (t) => { + const previous = instance(t); + const current = instance(t); + const requester = instance(t); + const previousSocket = await previous.connect("tab", "success", "previous"); + const closed = once(previousSocket, "close"); + await current.connect("tab", "success", "current"); + assert.equal((await closed)[0], 1008); + assert.equal((await requester.bridge.executePluginTask(new PluginTask("test", {}))).data, "current"); +}); diff --git a/mcp/packages/server/src/RedisBridge.ts b/mcp/packages/server/src/RedisBridge.ts index 89c01a4871..b653c70faf 100644 --- a/mcp/packages/server/src/RedisBridge.ts +++ b/mcp/packages/server/src/RedisBridge.ts @@ -1,6 +1,43 @@ import Redis from "ioredis"; -import { PluginTaskRequest, PluginTaskResponse } from "@penpot/mcp-common"; +import { PluginTaskRequest, PluginTaskResponse, PenpotSession } from "@penpot/mcp-common"; import { createLogger } from "./logger"; +import { z } from "zod"; + +/** Query for the user's sessions held by each subscribed MCP instance. */ +export interface SessionDiscoveryRequest { + id: string; +} + +/** One MCP instance's current sessions for the queried user. */ +export interface SessionDiscoveryResponse { + instanceId: string; + sessions: PenpotSession[]; +} + +/** Announcement that an MCP instance holds a new connection for a Penpot session. */ +export interface SessionClaim { + instanceId: string; + /** identifies the claiming connection, so an instance can recognize its own claims. */ + claimId: string; + sessionId: string; +} + +const sessionClaimSchema = z.object({ + instanceId: z.string().min(1), + claimId: z.string().min(1), + sessionId: z.string().min(1).max(128), +}); + +const discoveryResponseSchema = z.object({ + instanceId: z.string().min(1), + sessions: z.array( + z.object({ + sessionId: z.string().min(1).max(128), + fileId: z.string().min(1).max(128), + fileName: z.string(), + }) + ), +}); /** * Handler invoked for a task request arriving on a subscribed request channel. @@ -25,7 +62,7 @@ export type TaskResponseHandler = (response: PluginTaskResponse) => void; * * It enables a tool call handled on one instance to be executed against a plugin * whose WebSocket connection lives on another instance: the request is published on a - * channel keyed by user token (to which the instance holding the plugin connection is + * channel keyed by user token and session ID (to which the instance holding the plugin connection is * subscribed), and the response is published on a channel keyed by task ID (to which * the issuing instance subscribes). * @@ -64,14 +101,19 @@ export class RedisBridge { if (handler) { handler(rawMessage); } else { - this.logger.warn(`Received message on channel with no registered handler: ${channel}`); + this.logger.debug("Received Redis message without a registered handler"); } }); } - /** Builds the Redis Pub/Sub channel name for a task request addressed to a user token. */ - private requestChannel(userToken: string): string { - return `penpot.mcp.${this.tenant}.task.req.${userToken}`; + /** Builds a task channel scoped to both the user and the Penpot session. */ + private requestChannel(userToken: string, sessionId: string): string { + return `penpot.mcp.${this.tenant}.task.req.${this.channelKey(userToken)}.${this.channelKey(sessionId)}`; + } + + /** Encodes identifiers without ambiguous channel-name separators. */ + private channelKey(value: string): string { + return Buffer.from(value).toString("base64url"); } /** Builds the Redis Pub/Sub channel name for a task response keyed by task ID. */ @@ -81,7 +123,7 @@ export class RedisBridge { /** * Subscribes to the response channel for the given task ID and publishes the task - * request to the given user token's request channel. + * request to the given user's session channel. * * The response subscription is established *before* the request is published, to * avoid a race in which the response would be published before the subscription is @@ -89,20 +131,22 @@ export class RedisBridge { * removed automatically upon delivery (response channels are single-use). * * @param userToken - The user token identifying the target plugin's request channel + * @param sessionId - The Penpot session identifying the target connection * @param request - The serialized plugin task request, passed through verbatim * @param onResponse - Handler invoked with the response when it arrives * @returns The number of instances that received the request. A count of 0 means no - * instance is subscribed to the token's request channel (i.e. the plugin is not + * instance is subscribed to the session's request channel (i.e. the plugin is not * connected anywhere); the request was dropped, no response will ever arrive, and * the response subscription has already been released. */ async sendTaskRequest( userToken: string, + sessionId: string, request: PluginTaskRequest, onResponse: TaskResponseHandler ): Promise { const responseChannel = this.responseChannel(request.id); - const requestChannel = this.requestChannel(userToken); + const requestChannel = this.requestChannel(userToken, sessionId); this.handlers.set(responseChannel, (rawMessage) => { // a response channel is single-use: remove the handler and unsubscribe on delivery @@ -158,19 +202,22 @@ export class RedisBridge { */ publishTaskResponse(taskId: string, response: PluginTaskResponse): void { const responseChannel = this.responseChannel(taskId); - void this.publisher.publish(responseChannel, JSON.stringify(response)); + void this.publisher + .publish(responseChannel, JSON.stringify(response)) + .catch((error) => this.logger.error(error, "Failed to publish task response")); } /** - * Subscribes to task requests for the given user token. + * Subscribes to task requests for the given user's Penpot session. * - * The handler is invoked for each request arriving on the token's request channel. + * The handler is invoked for each request arriving on the session's request channel. * * @param userToken - The user token whose request channel to subscribe to + * @param sessionId - The Penpot session whose task channel to subscribe to * @param handler - The handler to invoke for incoming requests */ - async subscribeToTasks(userToken: string, handler: TaskRequestHandler): Promise { - const requestChannel = this.requestChannel(userToken); + async subscribeToTasks(userToken: string, sessionId: string, handler: TaskRequestHandler): Promise { + const requestChannel = this.requestChannel(userToken, sessionId); this.handlers.set(requestChannel, (rawMessage) => { try { handler(JSON.parse(rawMessage) as PluginTaskRequest); @@ -182,19 +229,121 @@ export class RedisBridge { } /** - * Unsubscribes from task requests for the given user token. + * Unsubscribes from task requests for the given user's Penpot session. * * @param userToken - The user token whose request channel to unsubscribe from + * @param sessionId - The Penpot session whose task channel to unsubscribe from */ - async unsubscribeFromTasks(userToken: string): Promise { - const requestChannel = this.requestChannel(userToken); + async unsubscribeFromTasks(userToken: string, sessionId: string): Promise { + const requestChannel = this.requestChannel(userToken, sessionId); this.handlers.delete(requestChannel); await this.subscriber.unsubscribe(requestChannel); } + private discoveryChannel(userToken: string): string { + return `penpot.mcp.${this.tenant}.discovery.req.${this.channelKey(userToken)}`; + } + + private discoveryResponseChannel(requestId: string): string { + return `penpot.mcp.${this.tenant}.discovery.res.${this.channelKey(requestId)}`; + } + + /** Subscribes to replies before publishing; returns the actual recipient count. */ + async sendDiscoveryRequest( + userToken: string, + requestId: string, + onResponse: (response: SessionDiscoveryResponse) => void + ): Promise { + const channel = this.discoveryResponseChannel(requestId); + this.handlers.set(channel, (raw) => { + try { + onResponse(discoveryResponseSchema.parse(JSON.parse(raw))); + } catch (error) { + this.logger.error(error, "Invalid discovery response"); + } + }); + try { + await this.subscriber.subscribe(channel); + return await this.publisher.publish(this.discoveryChannel(userToken), JSON.stringify({ id: requestId })); + } catch (error) { + await this.unsubscribeFromDiscoveryResponses(requestId); + throw error; + } + } + + async unsubscribeFromDiscoveryResponses(requestId: string): Promise { + const channel = this.discoveryResponseChannel(requestId); + this.handlers.delete(channel); + await this.subscriber.unsubscribe(channel); + } + + /** Registers one discovery responder for this user's local connections. */ + async subscribeToDiscovery(userToken: string, handler: (request: SessionDiscoveryRequest) => void): Promise { + const channel = this.discoveryChannel(userToken); + this.handlers.set(channel, (raw) => { + try { + handler(z.object({ id: z.string().min(1) }).parse(JSON.parse(raw))); + } catch (error) { + this.logger.error(error, "Invalid discovery request"); + } + }); + try { + await this.subscriber.subscribe(channel); + } catch (error) { + await this.unsubscribeFromDiscovery(userToken); + throw error; + } + } + + async unsubscribeFromDiscovery(userToken: string): Promise { + const channel = this.discoveryChannel(userToken); + this.handlers.delete(channel); + await this.subscriber.unsubscribe(channel); + } + + publishDiscoveryResponse(requestId: string, response: SessionDiscoveryResponse): void { + void this.publisher + .publish(this.discoveryResponseChannel(requestId), JSON.stringify(response)) + .catch((error) => this.logger.error(error, "Failed to publish discovery response")); + } + + private sessionClaimChannel(userToken: string): string { + return `penpot.mcp.${this.tenant}.session.claim.${this.channelKey(userToken)}`; + } + /** - * Closes both Redis connections. Call on server shutdown. + * Subscribes to the session claims of the given user. + * + * Claims are delivered in publication order, including the subscribing instance's own claims. */ + async subscribeToSessionClaims(userToken: string, handler: (claim: SessionClaim) => void): Promise { + const channel = this.sessionClaimChannel(userToken); + this.handlers.set(channel, (raw) => { + try { + handler(sessionClaimSchema.parse(JSON.parse(raw))); + } catch (error) { + this.logger.error(error, "Invalid session claim"); + } + }); + try { + await this.subscriber.subscribe(channel); + } catch (error) { + await this.unsubscribeFromSessionClaims(userToken); + throw error; + } + } + + async unsubscribeFromSessionClaims(userToken: string): Promise { + const channel = this.sessionClaimChannel(userToken); + this.handlers.delete(channel); + await this.subscriber.unsubscribe(channel); + } + + async publishSessionClaim(userToken: string, claim: SessionClaim): Promise { + await this.publisher.publish(this.sessionClaimChannel(userToken), JSON.stringify(claim)); + } + + /** Closes both Redis connections. Call on server shutdown. */ async close(): Promise { await this.subscriber.quit(); await this.publisher.quit(); diff --git a/mcp/packages/server/src/Tool.ts b/mcp/packages/server/src/Tool.ts index df4e1f2266..6ec44f2cb6 100644 --- a/mcp/packages/server/src/Tool.ts +++ b/mcp/packages/server/src/Tool.ts @@ -20,6 +20,16 @@ export class EmptyToolArgs { * @template TArgs - The strongly-typed arguments class for this tool */ export abstract class Tool { + public static readonly SESSION_ID_SCHEMA = z + .string() + .min(1) + .max(128) + .optional() + .describe( + "Penpot session to use. Optional when exactly one session is connected. " + + "If several sessions are listed in an error, ask the user to select one and pass its sessionId." + ); + private readonly logger = createLogger("Tool"); /** monotonically increasing counter for unique tool execution IDs */ diff --git a/mcp/packages/server/src/UserPenpotConnections.ts b/mcp/packages/server/src/UserPenpotConnections.ts new file mode 100644 index 0000000000..b9b816049d --- /dev/null +++ b/mcp/packages/server/src/UserPenpotConnections.ts @@ -0,0 +1,43 @@ +import type { PenpotSession } from "@penpot/mcp-common"; +import type { PenpotConnection } from "./PenpotConnection"; +import { WebSocket } from "ws"; + +/** Local Penpot connections belonging to one user, indexed by session ID. */ +export class UserPenpotConnections { + private readonly connectionsBySessionId = new Map(); + + get size(): number { + return this.connectionsBySessionId.size; + } + + /** Registers a connection, returning the connection it replaces for the same session, if any. */ + add(connection: PenpotConnection): PenpotConnection | undefined { + const { sessionId } = connection.session; + const replaced = this.connectionsBySessionId.get(sessionId); + this.connectionsBySessionId.set(sessionId, connection); + return replaced; + } + + remove(connection: PenpotConnection): void { + const { sessionId } = connection.session; + if (this.connectionsBySessionId.get(sessionId) === connection) { + this.connectionsBySessionId.delete(sessionId); + } + } + + /** Returns the registered connection for a session, whether or not it is ready. */ + getRegistered(sessionId: string): PenpotConnection | undefined { + return this.connectionsBySessionId.get(sessionId); + } + + get(sessionId: string): PenpotConnection | undefined { + const connection = this.connectionsBySessionId.get(sessionId); + return connection?.ready && connection.socket.readyState === WebSocket.OPEN ? connection : undefined; + } + + getSessions(): PenpotSession[] { + return [...this.connectionsBySessionId.values()] + .filter((connection) => connection.ready && connection.socket.readyState === WebSocket.OPEN) + .map((connection) => ({ ...connection.session })); + } +} diff --git a/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.test.ts b/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.test.ts new file mode 100644 index 0000000000..4c1b153916 --- /dev/null +++ b/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.test.ts @@ -0,0 +1,371 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PenpotSession, PluginTaskRequest, PluginTaskResponse } from "@penpot/mcp-common"; +import type { PenpotConnection } from "../PenpotConnection"; +import { PluginTask, type AbstractPluginTask } from "../PluginTask"; +import type { RedisBridge, SessionClaim, SessionDiscoveryRequest, SessionDiscoveryResponse } from "../RedisBridge"; +import { UserPenpotConnections } from "../UserPenpotConnections"; +import { MultiInstanceTaskDispatcher } from "./MultiInstanceTaskDispatcher"; +import { SessionDiscoveryError, type TaskDispatchHost } from "./TaskDispatcher"; + +/** In-memory Pub/Sub transport retaining Redis's per-subscriber delivery count. */ +class RedisNetwork { + readonly tasks = new Map void>>(); + readonly discovery = new Map void>>(); + readonly taskResponses = new Map) => void>(); + readonly discoveryResponses = new Map void>(); + readonly claims = new Map void>>(); + failDiscovery = false; + dropClaims = false; + beforeDiscoveryPublish?: () => Promise; + beforeClaimPublish?: () => Promise; +} + +/** One MCP instance's connection to the test Pub/Sub network. */ +class RedisPeer { + replyToDiscovery = true; + repeatDiscoveryReplies = false; + + constructor(readonly network: RedisNetwork) {} + + asBridge(): RedisBridge { + return this as unknown as RedisBridge; + } + + async subscribeToTasks(token: string, session: string, handler: (request: PluginTaskRequest) => void) { + const key = JSON.stringify([token, session]); + const subscribers = this.network.tasks.get(key) ?? new Map(); + subscribers.set(this, handler); + this.network.tasks.set(key, subscribers); + } + + async unsubscribeFromTasks(token: string, session: string) { + this.network.tasks.get(JSON.stringify([token, session]))?.delete(this); + } + + async sendTaskRequest( + token: string, + session: string, + request: PluginTaskRequest, + onResponse: (response: PluginTaskResponse) => void + ) { + this.network.taskResponses.set(request.id, onResponse); + const subscribers = this.network.tasks.get(JSON.stringify([token, session])) ?? new Map(); + for (const handler of subscribers.values()) handler(request); + if (!subscribers.size) this.network.taskResponses.delete(request.id); + return subscribers.size; + } + + publishTaskResponse(id: string, response: PluginTaskResponse) { + const handler = this.network.taskResponses.get(id); + this.network.taskResponses.delete(id); + handler?.(response); + } + + async subscribeToDiscovery(token: string, handler: (request: SessionDiscoveryRequest) => void) { + const subscribers = this.network.discovery.get(token) ?? new Map(); + subscribers.set(this, handler); + this.network.discovery.set(token, subscribers); + } + + async unsubscribeFromDiscovery(token: string) { + this.network.discovery.get(token)?.delete(this); + } + + async sendDiscoveryRequest(token: string, id: string, onResponse: (response: SessionDiscoveryResponse) => void) { + if (this.network.failDiscovery) throw new Error("Redis unavailable for discovery"); + this.network.discoveryResponses.set(id, onResponse); + await this.network.beforeDiscoveryPublish?.(); + const subscribers = this.network.discovery.get(token) ?? new Map(); + for (const [peer, handler] of subscribers) { + if (peer.replyToDiscovery) { + handler({ id }); + if (peer.repeatDiscoveryReplies) handler({ id }); + } + } + return subscribers.size; + } + + publishDiscoveryResponse(id: string, response: SessionDiscoveryResponse) { + this.network.discoveryResponses.get(id)?.(response); + } + + async unsubscribeFromDiscoveryResponses(id: string) { + this.network.discoveryResponses.delete(id); + } + + async subscribeToSessionClaims(token: string, handler: (claim: SessionClaim) => void) { + const subscribers = this.network.claims.get(token) ?? new Map(); + subscribers.set(this, handler); + this.network.claims.set(token, subscribers); + } + + async unsubscribeFromSessionClaims(token: string) { + this.network.claims.get(token)?.delete(this); + } + + async publishSessionClaim(token: string, claim: SessionClaim) { + await this.network.beforeClaimPublish?.(); + if (this.network.dropClaims) return; + for (const handler of [...(this.network.claims.get(token)?.values() ?? [])]) handler(claim); + } +} + +/** Local registry and execution endpoint used with the real dispatchers. */ +class Instance implements TaskDispatchHost { + readonly users = new Map(); + readonly redis: RedisPeer; + readonly dispatcher: MultiInstanceTaskDispatcher; + readonly executions: string[] = []; + readonly displaced: string[] = []; + + constructor(network: RedisNetwork) { + this.redis = new RedisPeer(network); + this.dispatcher = new MultiInstanceTaskDispatcher(this, this.redis.asBridge(), 20); + } + + async connect(token: string, sessionId: string, fileName = "Design") { + const session: PenpotSession = { sessionId, fileId: "same-file", fileName }; + const connection = { userToken: token, session, ready: false, socket: { readyState: 1 } } as PenpotConnection; + const connections = this.users.get(token) ?? new UserPenpotConnections(); + this.users.set(token, connections); + connections.add(connection); + await this.dispatcher.onNewConnection(connection); + connection.ready = true; + return connection; + } + + async disconnect(connection: PenpotConnection) { + this.users.get(connection.userToken!)!.remove(connection); + connection.ready = false; + await this.dispatcher.onConnectionClosed(connection); + } + + displaceConnection(connection: PenpotConnection) { + this.displaced.push(connection.session.sessionId); + void this.disconnect(connection); + } + + getUserConnections(token: string | null) { + return this.users.get(token!); + } + + sendLocalTask(task: AbstractPluginTask, connection: PenpotConnection) { + if (!connection.ready) throw new Error("Plugin disconnected"); + this.executions.push(connection.session.sessionId); + task.resolveWithResult({ data: connection.session.sessionId }); + } + + sendRemoteTask(task: AbstractPluginTask, token: string, sessionId: string) { + void this.redis + .sendTaskRequest(token, sessionId, task.toRequest(), (response) => { + if (response.success) task.resolveWithResult({ data: response.data }); + else task.rejectWithError(new Error(response.error)); + }) + .then((count) => { + if (!count) task.rejectWithError(new Error("Session not connected")); + }); + } + + async execute(token: string, sessionId?: string) { + const task = new PluginTask("test", {}); + await this.dispatcher.dispatch(task, token, sessionId); + return await task.getResultPromise(); + } +} + +test("discovers all sessions across instances, including several tabs for one file", async () => { + const network = new RedisNetwork(); + const first = new Instance(network); + const second = new Instance(network); + await first.connect("alice", "one"); + await first.connect("alice", "two"); + await second.connect("alice", "three"); + await second.connect("bob", "private"); + const sessions = await new Instance(network).dispatcher.discoverSessions("alice"); + assert.deepEqual(sessions.map((session) => session.sessionId).sort(), ["one", "three", "two"]); + assert.equal(network.discoveryResponses.size, 0); +}); + +test("explicit sessions dispatch remotely without discovery", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + const requester = new Instance(network); + await owner.connect("alice", "selected"); + network.failDiscovery = true; + assert.equal((await requester.execute("alice", "selected")).data, "selected"); +}); + +test("explicit sessions work through Redis on their owning instance too", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + await owner.connect("alice", "selected"); + network.failDiscovery = true; + assert.equal((await owner.execute("alice", "selected")).data, "selected"); +}); + +test("explicit session IDs cannot route tasks across user boundaries", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + await owner.connect("bob", "private"); + await assert.rejects(new Instance(network).execute("alice", "private"), /not connected/); + assert.deepEqual(owner.executions, []); +}); + +test("complete discovery dispatches when exactly one session exists", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + await owner.connect("alice", "only"); + assert.equal((await new Instance(network).execute("alice")).data, "only"); +}); + +test("complete discovery requires selection when more than one session exists", async () => { + const network = new RedisNetwork(); + await new Instance(network).connect("alice", "first", "First file"); + await new Instance(network).connect("alice", "second", "Second file"); + await assert.rejects(new Instance(network).execute("alice"), (error: Error) => { + assert.match(error.message, /Ask the user/); + assert.match(error.message, /first.*First file/); + assert.match(error.message, /second.*Second file/); + return true; + }); +}); + +test("zero discovery subscribers reports no connected sessions", async () => { + await assert.rejects(new Instance(new RedisNetwork()).execute("alice"), /No Penpot sessions/); +}); + +test("incomplete discovery never dispatches even when it yields exactly one session", async () => { + const network = new RedisNetwork(); + const responsive = new Instance(network); + const slow = new Instance(network); + await responsive.connect("alice", "visible"); + await slow.connect("alice", "hidden"); + slow.redis.replyToDiscovery = false; + await assert.rejects(new Instance(network).execute("alice"), (error: Error) => { + assert.ok(error instanceof SessionDiscoveryError); + assert.match(error.message, /temporary server load.*retry/); + return true; + }); + assert.deepEqual(responsive.executions, []); + assert.equal(network.discoveryResponses.size, 0); +}); + +test("duplicate replies from one instance do not substitute for a missing reply", async () => { + const network = new RedisNetwork(); + const responsive = new Instance(network); + const slow = new Instance(network); + await responsive.connect("alice", "visible"); + await slow.connect("alice", "hidden"); + responsive.redis.repeatDiscoveryReplies = true; + slow.redis.replyToDiscovery = false; + await assert.rejects(new Instance(network).execute("alice"), SessionDiscoveryError); +}); + +test("discovery transport failures use the retryable discovery error", async () => { + const network = new RedisNetwork(); + network.failDiscovery = true; + await assert.rejects(new Instance(network).execute("alice"), SessionDiscoveryError); +}); + +test("disconnecting one tab retains discovery and dispatch for the remaining tab", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + const first = await owner.connect("alice", "first"); + await owner.connect("alice", "second"); + await owner.disconnect(first); + assert.equal((await new Instance(network).execute("alice")).data, "second"); +}); + +test("disconnecting the last tab removes its discovery subscription", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + const connection = await owner.connect("alice", "only"); + await owner.disconnect(connection); + assert.equal(network.discovery.get("alice")?.size, 0); + await assert.rejects(new Instance(network).execute("alice"), /No Penpot sessions/); +}); + +test("concurrent discoveries keep response channels and users separate", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + const requester = new Instance(network); + await owner.connect("alice", "alice-tab"); + await owner.connect("bob", "bob-tab"); + const results = await Promise.all([requester.execute("alice"), requester.execute("bob")]); + assert.deepEqual( + results.map((result) => result.data), + ["alice-tab", "bob-tab"] + ); + assert.equal(network.discoveryResponses.size, 0); +}); + +test("a publish completing after timeout cannot dispatch or retain its response subscription", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + await owner.connect("alice", "late"); + let release!: () => void; + network.beforeDiscoveryPublish = () => + new Promise((resolve) => { + release = resolve; + }); + await assert.rejects(new Instance(network).execute("alice"), SessionDiscoveryError); + release(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(owner.executions, []); + assert.equal(network.discoveryResponses.size, 0); +}); + +test("a newer connection for a session on another instance displaces the older one", async () => { + const network = new RedisNetwork(); + const previous = new Instance(network); + const current = new Instance(network); + await previous.connect("alice", "tab"); + await current.connect("alice", "tab"); + assert.deepEqual(previous.displaced, ["tab"]); + assert.deepEqual(current.displaced, []); + assert.equal((await new Instance(network).execute("alice")).data, "tab"); + assert.deepEqual(previous.executions, []); + assert.deepEqual(current.executions, ["tab"]); +}); + +test("a claim published before a local connection's own claim does not displace it", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + const other = new Instance(network); + await owner.connect("alice", "sibling"); + let release!: () => void; + network.beforeClaimPublish = () => + new Promise((resolve) => { + release = resolve; + }); + const pending = owner.connect("alice", "tab"); + await new Promise((resolve) => setImmediate(resolve)); + network.beforeClaimPublish = undefined; + // the other instance's claim is delivered first, so the owner's connection is the newer one + await other.connect("alice", "tab"); + release(); + await pending; + assert.deepEqual(owner.displaced, []); + assert.deepEqual(other.displaced, ["tab"]); +}); + +test("discovery lists a session reported by several instances once", async () => { + const network = new RedisNetwork(); + network.dropClaims = true; + await new Instance(network).connect("alice", "tab"); + await new Instance(network).connect("alice", "tab"); + const sessions = await new Instance(network).dispatcher.discoverSessions("alice"); + assert.deepEqual( + sessions.map((session) => session.sessionId), + ["tab"] + ); +}); + +test("disconnecting the last tab removes its session claim subscription", async () => { + const network = new RedisNetwork(); + const owner = new Instance(network); + const connection = await owner.connect("alice", "only"); + await owner.disconnect(connection); + assert.equal(network.claims.get("alice")?.size, 0); +}); diff --git a/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.ts b/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.ts new file mode 100644 index 0000000000..10c895e659 --- /dev/null +++ b/mcp/packages/server/src/dispatch/MultiInstanceTaskDispatcher.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import type { PenpotSession, PluginTaskRequest } from "@penpot/mcp-common"; +import type { PenpotConnection } from "../PenpotConnection"; +import type { AbstractPluginTask } from "../PluginTask"; +import { RemotePluginTask } from "../RemotePluginTask"; +import type { RedisBridge, SessionClaim, SessionDiscoveryResponse } from "../RedisBridge"; +import { createLogger } from "../logger"; +import { SessionDiscoveryError, TaskDispatcher, type TaskDispatchHost } from "./TaskDispatcher"; + +/** Routes tasks over Redis and discovers live sessions across MCP instances. */ +export class MultiInstanceTaskDispatcher extends TaskDispatcher { + private readonly logger = createLogger("MultiInstanceTaskDispatcher"); + private readonly instanceId = randomUUID(); + private readonly subscribedUsers = new Set(); + private readonly connectionChanges = new Map>(); + private readonly pendingDiscoveries = new Map void>(); + private readonly claimIds = new WeakMap(); + /** local connections whose own claim has been delivered back to this instance. */ + private readonly confirmedClaims = new WeakSet(); + + constructor( + private readonly host: TaskDispatchHost, + private readonly redisBridge: RedisBridge, + private readonly discoveryTimeoutMs = 5000 + ) { + super(); + } + + async dispatch(task: AbstractPluginTask, userToken: string | null, sessionId?: string): Promise { + const token = this.requireUserToken(userToken); + const targetId = sessionId ?? this.selectOnlySession(await this.discoverSessions(token)); + this.host.sendRemoteTask(task, token, targetId); + } + + async discoverSessions(userToken: string | null): Promise { + const token = this.requireUserToken(userToken); + const requestId = randomUUID(); + const responses = new Map(); + let expectedResponses: number | undefined; + let settled = false; + let resolve!: (sessions: PenpotSession[]) => void; + let reject!: (error: Error) => void; + const result = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const fail = () => { + if (!settled) { + settled = true; + reject(new SessionDiscoveryError()); + } + }; + const complete = () => { + if (!settled && expectedResponses !== undefined && responses.size === expectedResponses) { + settled = true; + resolve(this.uniqueSessions([...responses.values()].flat())); + } + }; + const timeout = setTimeout(fail, this.discoveryTimeoutMs); + this.pendingDiscoveries.set(requestId, fail); + const releaseResponses = () => { + void this.redisBridge + .unsubscribeFromDiscoveryResponses(requestId) + .catch((error) => this.logger.error(error, "Failed to release discovery response subscription")); + }; + // Replies can arrive before publish returns its recipient count. + void this.redisBridge + .sendDiscoveryRequest(token, requestId, (response: SessionDiscoveryResponse) => { + if (settled) return; + responses.set(response.instanceId, response.sessions); + complete(); + }) + .then((count) => { + expectedResponses = count; + complete(); + // A slow subscribe/publish may finish after the discovery timeout. + if (settled) releaseResponses(); + }) + .catch(fail); + try { + return await result; + } finally { + clearTimeout(timeout); + this.pendingDiscoveries.delete(requestId); + releaseResponses(); + } + } + + async onNewConnection(connection: PenpotConnection): Promise { + const token = this.requireUserToken(connection.userToken); + return this.changeConnections(token, async () => { + // subscribe to this session's Redis request channel so that task requests + // issued by other instances are dispatched to this instance + await this.redisBridge.subscribeToTasks(token, connection.session.sessionId, (request) => + this.dispatchForwardedTask(connection, request) + ); + if (!this.subscribedUsers.has(token)) { + await this.redisBridge.subscribeToDiscovery(token, (request) => { + this.redisBridge.publishDiscoveryResponse(request.id, { + instanceId: this.instanceId, + sessions: this.host.getUserConnections(token)?.getSessions() ?? [], + }); + }); + try { + await this.redisBridge.subscribeToSessionClaims(token, (claim) => + this.handleSessionClaim(token, claim) + ); + } catch (error) { + await this.redisBridge.unsubscribeFromDiscovery(token); + throw error; + } + this.subscribedUsers.add(token); + } + // announce the connection so that other instances close older connections for the session + const claimId = randomUUID(); + this.claimIds.set(connection, claimId); + try { + await this.redisBridge.publishSessionClaim(token, { + instanceId: this.instanceId, + claimId, + sessionId: connection.session.sessionId, + }); + } catch (error) { + this.logger.error(error, "Failed to publish session claim"); + } + }); + } + + /** + * Closes the local connection for a claimed session when the claim comes from a newer connection. + * + * Claims arrive in publication order, so a foreign claim arriving after the local connection's own + * claim comes from a newer connection. A foreign claim arriving earlier comes from an older + * connection, which its instance closes when the local claim reaches it. + */ + private handleSessionClaim(userToken: string, claim: SessionClaim): void { + const local = this.host.getUserConnections(userToken)?.getRegistered(claim.sessionId); + if (!local) return; + if (claim.instanceId === this.instanceId) { + if (this.claimIds.get(local) === claim.claimId) this.confirmedClaims.add(local); + return; + } + if (this.confirmedClaims.has(local)) { + this.host.displaceConnection(local); + } + } + + /** Removes repeated sessions reported while a displaced connection is being closed. */ + private uniqueSessions(sessions: PenpotSession[]): PenpotSession[] { + const unique = new Map(); + for (const session of sessions) { + if (!unique.has(session.sessionId)) unique.set(session.sessionId, session); + } + return [...unique.values()]; + } + + async onConnectionClosed(connection: PenpotConnection): Promise { + const token = this.requireUserToken(connection.userToken); + return this.changeConnections(token, async () => { + await this.redisBridge.unsubscribeFromTasks(token, connection.session.sessionId); + if (!this.host.getUserConnections(token)?.size && this.subscribedUsers.has(token)) { + this.subscribedUsers.delete(token); + await this.redisBridge.unsubscribeFromDiscovery(token); + await this.redisBridge.unsubscribeFromSessionClaims(token); + } + }); + } + + async close(): Promise { + for (const fail of this.pendingDiscoveries.values()) fail(); + await Promise.allSettled(this.connectionChanges.values()); + } + + /** + * Dispatches a task request received over Redis to the locally-connected plugin. + * + * Invoked on the instance subscribed to a session's request channel when another + * instance (or this one) issues a task request. A {@link RemotePluginTask} is created + * so that, once the plugin responds, the outcome is published back to the issuing + * instance's Redis response channel via the standard response-handling path. + * + * On failure to dispatch (e.g. the plugin is not connected here), an error response + * is published immediately so the requester need not wait for its timeout. + * + * @param connection - The local connection owning the session's request channel + * @param request - The serialized task request, passed through from Redis + */ + private dispatchForwardedTask(connection: PenpotConnection, request: PluginTaskRequest): void { + // The response is published on the channel keyed by the original request ID. + const task = new RemotePluginTask(request.task, request.params, this.redisBridge, request.id); + this.logger.debug("Dispatching remote task %s as %s to Penpot via WebSocket", request.id, task.id); + + try { + this.host.sendLocalTask(task, connection); + } catch (error) { + task.rejectWithError(error instanceof Error ? error : new Error(String(error))); + } + } + + /** Serializes subscription changes for a user across connects and disconnects. */ + private changeConnections(userToken: string, change: () => Promise): Promise { + const previous = this.connectionChanges.get(userToken) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(change); + this.connectionChanges.set(userToken, current); + const cleanup = () => { + if (this.connectionChanges.get(userToken) === current) this.connectionChanges.delete(userToken); + }; + void current.then(cleanup, cleanup); + return current; + } + + private requireUserToken(userToken: string | null): string { + if (!userToken) throw new Error("Multi-instance task routing requires a user token."); + return userToken; + } +} diff --git a/mcp/packages/server/src/dispatch/SingleInstanceTaskDispatcher.ts b/mcp/packages/server/src/dispatch/SingleInstanceTaskDispatcher.ts new file mode 100644 index 0000000000..60ed052105 --- /dev/null +++ b/mcp/packages/server/src/dispatch/SingleInstanceTaskDispatcher.ts @@ -0,0 +1,27 @@ +import type { PenpotSession } from "@penpot/mcp-common"; +import type { AbstractPluginTask } from "../PluginTask"; +import { TaskDispatcher, type TaskDispatchHost } from "./TaskDispatcher"; + +/** Routes tasks to connections held by this MCP server instance. */ +export class SingleInstanceTaskDispatcher extends TaskDispatcher { + constructor(private readonly host: TaskDispatchHost) { + super(); + } + + async discoverSessions(userToken: string | null): Promise { + return this.host.getUserConnections(userToken)?.getSessions() ?? []; + } + + async dispatch(task: AbstractPluginTask, userToken: string | null, sessionId?: string): Promise { + const targetId = sessionId ?? this.selectOnlySession(await this.discoverSessions(userToken)); + const connection = this.host.getUserConnections(userToken)?.get(targetId); + if (!connection) { + throw new Error(`Penpot session ${JSON.stringify(targetId)} is not connected for this user.`); + } + this.host.sendLocalTask(task, connection); + } + + async onNewConnection(): Promise {} + async onConnectionClosed(): Promise {} + async close(): Promise {} +} diff --git a/mcp/packages/server/src/dispatch/TaskDispatcher.ts b/mcp/packages/server/src/dispatch/TaskDispatcher.ts new file mode 100644 index 0000000000..b9fdcf17aa --- /dev/null +++ b/mcp/packages/server/src/dispatch/TaskDispatcher.ts @@ -0,0 +1,49 @@ +import type { PenpotSession } from "@penpot/mcp-common"; +import type { PenpotConnection } from "../PenpotConnection"; +import type { AbstractPluginTask } from "../PluginTask"; +import type { UserPenpotConnections } from "../UserPenpotConnections"; + +/** Local connection access and tracked task execution provided by PluginBridge. */ +export interface TaskDispatchHost { + getUserConnections(userToken: string | null): UserPenpotConnections | undefined; + sendLocalTask(task: AbstractPluginTask, connection: PenpotConnection): void; + sendRemoteTask(task: AbstractPluginTask, userToken: string, sessionId: string): void; + displaceConnection(connection: PenpotConnection): void; +} + +/** Session discovery, task routing, and connection subscription lifecycle. */ +export abstract class TaskDispatcher { + abstract discoverSessions(userToken: string | null): Promise; + abstract dispatch(task: AbstractPluginTask, userToken: string | null, sessionId?: string): Promise; + abstract onNewConnection(connection: PenpotConnection): Promise; + abstract onConnectionClosed(connection: PenpotConnection): Promise; + abstract close(): Promise; + + /** Selects the sole session from a complete discovery result. */ + protected selectOnlySession(sessions: PenpotSession[]): string { + if (sessions.length === 0) { + throw new Error("No Penpot sessions are connected. Please connect the Penpot MCP plugin and retry."); + } + if (sessions.length !== 1) { + throw new Error( + "Multiple Penpot sessions are connected. Ask the user to select one of the active sessions, " + + "then retry with its sessionId:\n" + + sessions + .map( + (session) => + `${session.sessionId}: ${JSON.stringify(session.fileName)} (file ${session.fileId})` + ) + .join("\n") + ); + } + return sessions[0].sessionId; + } +} + +/** Error returned when discovery cannot establish the complete set of sessions. */ +export class SessionDiscoveryError extends Error { + constructor() { + super("Penpot session discovery failed. This may be due to temporary server load. You can retry the request."); + this.name = "SessionDiscoveryError"; + } +} diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index 6a514e0cf4..ee15401005 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -16,12 +16,15 @@ export class ExecuteCodeArgs { .string() .min(1, "Code cannot be empty") .describe("The JavaScript code to execute in the plugin context."), + sessionId: Tool.SESSION_ID_SCHEMA, }; /** * The JavaScript code to execute in the plugin context. */ code!: string; + + sessionId?: string; } /** @@ -67,7 +70,7 @@ export class ExecuteCodeTool extends Tool { protected async executeCore(args: ExecuteCodeArgs): Promise { const taskParams: ExecuteCodeTaskParams = { code: args.code }; const task = new ExecuteCodePluginTask(taskParams); - const result = await this.mcpServer.pluginBridge.executePluginTask(task); + const result = await this.mcpServer.pluginBridge.executePluginTask(task, args.sessionId); if (result.data !== undefined) { return new TextResponse(JSON.stringify(result.data, null, 2)); diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index 0d84df9380..7eb3e321c9 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -36,6 +36,7 @@ export class ExportShapeArgs { "Optional file path to save the exported image to. If not provided, " + "the image data is returned directly for you to see." ), + sessionId: Tool.SESSION_ID_SCHEMA, }; shapeId!: string; @@ -45,6 +46,8 @@ export class ExportShapeArgs { mode: "shape" | "fill" = "shape"; filePath?: string; + + sessionId?: string; } /** @@ -151,7 +154,7 @@ export class ExportShapeTool extends Tool { // execute the code and obtain the image data const task = new ExecuteCodePluginTask({ code: code }); - const result = await this.mcpServer.pluginBridge.executePluginTask(task); + const result = await this.mcpServer.pluginBridge.executePluginTask(task, args.sessionId); const imageData = result.data!.result; // handle output and return response diff --git a/mcp/packages/server/src/tools/ImportImageTool.ts b/mcp/packages/server/src/tools/ImportImageTool.ts index 7581aaf525..d5ffb5eb9b 100644 --- a/mcp/packages/server/src/tools/ImportImageTool.ts +++ b/mcp/packages/server/src/tools/ImportImageTool.ts @@ -30,6 +30,7 @@ export class ImportImageArgs { .describe( "Optional height for the rectangle. If only height is provided, width is calculated to maintain aspect ratio." ), + sessionId: Tool.SESSION_ID_SCHEMA, }; filePath!: string; @@ -41,6 +42,8 @@ export class ImportImageArgs { width?: number; height?: number; + + sessionId?: string; } /** @@ -116,7 +119,7 @@ export class ImportImageTool extends Tool { return { shapeId: rectangle.id }; `; const task = new ExecuteCodePluginTask({ code: code }); - const executionResult = await this.mcpServer.pluginBridge.executePluginTask(task); + const executionResult = await this.mcpServer.pluginBridge.executePluginTask(task, args.sessionId); return new TextResponse(JSON.stringify(executionResult.data?.result, null, 2)); } From 7c039231f79022f26b252776817c41fa61476aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 2 Oct 2026 15:07:12 +0200 Subject: [PATCH 023/325] :tada: Implement paste of basic HTML formatting (v3) (#12050) * :sparkles: Add rich HTML paste to the v3 text editor Pasting HTML into the v3 text editor keeps bold, italic, decoration and text-transform, and drops fonts, colors and links. Gated behind text-editor-wasm/v1-html-paste. Refs #10465 AI-assisted-by: claude-opus-5-5 * :sparkles: Paste HTML on the canvas as a styled text shape With v3 HTML paste on, pasting HTML on the canvas creates a text shape with the same emphasis. Clipboard options are now read at paste time. Refs #10465 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/features.cljc | 3 + .../app/main/data/workspace/clipboard.cljs | 32 ++- .../ui/workspace/shapes/text/v3_editor.cljs | 69 ++++- frontend/src/app/render_wasm/api.cljs | 13 + frontend/src/app/render_wasm/text_editor.cljs | 2 +- frontend/src/app/render_wasm/text_paste.cljs | 92 +++++++ frontend/src/app/util/text/clipboard.cljs | 254 ++++++++++++++++++ .../render_wasm/text_paste_test.cljs | 146 ++++++++++ frontend/test/frontend_tests/runner.cljs | 4 + .../util_text_clipboard_test.cljs | 165 ++++++++++++ 10 files changed, 756 insertions(+), 24 deletions(-) create mode 100644 frontend/src/app/render_wasm/text_paste.cljs create mode 100644 frontend/src/app/util/text/clipboard.cljs create mode 100644 frontend/test/frontend_tests/render_wasm/text_paste_test.cljs create mode 100644 frontend/test/frontend_tests/util_text_clipboard_test.cljs diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index c29b99aee3..281ed4c9bb 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -56,6 +56,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" + "text-editor-wasm/v1-html-paste" "render-wasm/v1" "variants/v1"}) @@ -81,6 +82,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" + "text-editor-wasm/v1-html-paste" "tokens/numeric-input" "render-wasm/v1"}) @@ -131,6 +133,7 @@ :feature-text-editor-v2 "text-editor/v2" :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-wasm "text-editor-wasm/v1" + :feature-text-editor-wasm-html-paste "text-editor-wasm/v1-html-paste" :feature-render-wasm "render-wasm/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 7145bd3ad5..fabf3e3fed 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -53,12 +53,14 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.streams :as ms] + [app.render-wasm.text-paste :as text-paste] [app.util.clipboard :as clipboard] [app.util.code-gen.markup-svg :as svg] [app.util.code-gen.style-css :as css] [app.util.globals :as ug] [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] + [app.util.text.clipboard :as text-clipboard] [app.util.text.content :as tc] [app.util.webapi :as wapi] [beicon.v2.core :as rx] @@ -261,9 +263,15 @@ (declare ^:private paste-svg-text) (declare ^:private paste-shapes) -(def ^:private default-options +(defn- v3-html-paste? + [state] + (and (features/active-feature? state "text-editor-wasm/v1") + (features/active-feature? state "text-editor-wasm/v1-html-paste"))) + +(defn- clipboard-options + [state] #js {:decodeTransit t/decode-str - :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}) + :allowHTMLPaste (v3-html-paste? state)}) (defn- create-paste-from-blob [in-viewport? replace?] @@ -328,7 +336,7 @@ ptk/WatchEvent (watch [_ state _] (if (page-ready? (dsh/lookup-page-objects state)) - (->> (clipboard/from-navigator default-options) + (->> (clipboard/from-navigator (clipboard-options state)) (rx/mapcat (create-paste-from-blob false (boolean replace?))) (rx/take 1) (rx/catch on-clipboard-permission-error)) @@ -349,7 +357,7 @@ ;; Pastes arriving before the page is loaded are ignored as well. (if (or is-editing? (not (page-ready? objects))) (rx/empty) - (->> (clipboard/from-synthetic-clipboard-event event default-options) + (->> (clipboard/from-synthetic-clipboard-event event (clipboard-options state)) (rx/mapcat (create-paste-from-blob in-viewport? false)))))))) (defn copy-selected-svg @@ -529,7 +537,7 @@ (rx/empty)))))] (if (page-ready? (dsh/lookup-page-objects state)) - (->> (clipboard/from-navigator default-options) + (->> (clipboard/from-navigator (clipboard-options state)) (rx/mapcat #(.text %)) (rx/map decode-entry) (rx/take 1) @@ -1057,11 +1065,15 @@ (ptk/reify ::paste-html-text ptk/WatchEvent (watch [_ state _] - (let [style (deref refs/workspace-clipboard-style) - root (dwtxt/create-root-from-html html style (features/active-feature? @st/state "text-editor/v2-html-paste")) - text (.-textContent root) - content (tc/dom->cljs root)] - (when (types.text/valid-content? content) + (let [[text content] + (if (v3-html-paste? state) + (when-let [fragment (text-clipboard/html->fragment html)] + [(text-paste/fragment->text fragment) + (text-paste/fragment->content fragment (txt/get-default-text-attrs))]) + (let [style (deref refs/workspace-clipboard-style) + root (dwtxt/create-root-from-html html style (features/active-feature? @st/state "text-editor/v2-html-paste"))] + [(.-textContent root) (tc/dom->cljs root)]))] + (when (and (some? content) (types.text/valid-content? content)) (let [id (uuid/next) width (max 8 (min (* 7 (count text)) 700)) height 16 diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 1c95406ef5..8ed1cb50fe 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -15,14 +15,17 @@ [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.undo :as dwu] + [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.css-cursors :as cur] [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as text-editor] + [app.render-wasm.text-paste :as text-paste] [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.keyboard :as kbd] + [app.util.text.clipboard :as text-clipboard] [app.util.timers :as ts] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -78,6 +81,16 @@ {:start-para (:para after) :start-offset (:offset after) :end-para (:para before) :end-offset (:offset before)}))) +(defn- commit-restyled-content + "Save `content` restyled after an insertion, renaming the shape after its text." + [shape-id content] + (let [text (txt/content->text content) + name (when (not= text "") (txt/generate-shape-name text))] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :name name)))) + (defn- sync-with-pending-caret-styles! "Commit an insertion that consumed a pending caret style: sync the new text, then restyle the just-typed `range` into its own span. `before` is the @@ -87,12 +100,43 @@ ;; Sync first so the cached content stays index-aligned with WASM. (text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] - (let [text (txt/content->text content) - name (when (not= text "") (txt/generate-shape-name text))] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :name name))) + (commit-restyled-content shape-id content) + (sync-wasm-text-editor-content!)))) + +;; Bigger HTML is pasted as plain text rather than walked. +(def ^:private max-paste-html-length 1000000) + +(defn- clipboard->fragment + "The paste fragment for `data`: its HTML when allowed and it has text, else its plain text." + [^js data html-paste?] + (let [html (when html-paste? (.getData data "text/html"))] + (or (when (< 0 (count html) max-paste-html-length) + (text-clipboard/html->fragment html)) + (text-clipboard/text->fragment (.getData data "text/plain"))))) + +(defn- selection-start + "Start of the WASM selection as {:para :offset}: where pasted text goes." + [] + (when-let [{:keys [anchor-para anchor-offset focus-para focus-offset]} + (text-editor/text-editor-get-selection)] + (if (or (< anchor-para focus-para) + (and (= anchor-para focus-para) (<= anchor-offset focus-offset))) + {:para anchor-para :offset anchor-offset} + {:para focus-para :offset focus-offset}))) + +(defn- paste-fragment + "Insert the text of `fragment`, then restyle it with the fragment overrides." + [fragment] + (let [shape-id (text-editor/text-editor-get-active-shape-id) + start (selection-start)] + (text-editor/text-editor-insert-text (text-paste/fragment->text fragment)) + (if (and (some? start) (text-paste/styled? fragment)) + (do + ;; Sync first so the cached content stays index-aligned with WASM. + (text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start)] + (commit-restyled-content shape-id content) + (sync-wasm-text-editor-content!))) (sync-wasm-text-editor-content!)))) (defn- reset-input-node @@ -273,13 +317,12 @@ (dom/prevent-default event) ;; Pasted text keeps the surrounding style; drop any pending caret style. (text-editor/clear-pending-caret-styles!) - (let [clipboard-data (.-clipboardData event) - text (.getData clipboard-data "text/plain")] - (when (and text (seq text)) - (text-editor/text-editor-insert-text text) - (sync-wasm-text-editor-content!) - (wasm.api/request-render-preserving-target "text-paste")) - (reset-input-node (mf/ref-val contenteditable-ref))))) + (when-let [fragment (some-> (.-clipboardData event) + (clipboard->fragment + (features/active-feature? @st/state "text-editor-wasm/v1-html-paste")))] + (paste-fragment fragment) + (wasm.api/request-render-preserving-target "text-paste")) + (reset-input-node (mf/ref-val contenteditable-ref)))) on-copy (mf/use-fn diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index d9340bc5c5..59ad963352 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -51,6 +51,7 @@ [app.render-wasm.performance :as perf] [app.render-wasm.rulers-state :as rulers-state] [app.render-wasm.text-editor :as text-editor] + [app.render-wasm.text-paste :as text-paste] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.functions :as fns] @@ -805,6 +806,18 @@ (request-render "apply-pending-caret-styles") result))) +(defn apply-paste-styles + "Restyle the text just pasted at `start` with the overrides of `fragment`; + returns {:shape-id :content}, or nil when the shape has no cached content." + [shape-id fragment start] + (when-let [content (text-editor/get-cached-content shape-id)] + (let [content (text-paste/apply-fragment-styles content fragment start)] + (wselect/use-shape shape-id) + (set-shape-text-content shape-id content) + (request-render "apply-paste-styles") + {:shape-id shape-id + :content content}))) + (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 2f60004eac..ed82300c09 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -739,7 +739,7 @@ (= 1 (count fills-set)) (first fills-set) :else :multiple))) -(defn- apply-styles-over-range +(defn apply-styles-over-range "Apply `styles` (attrs map or per-span fn) to the char range of `content`, splitting spans." [content {:keys [start-para start-offset end-para end-offset]} styles] (let [paragraph-set (first (:children content)) diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs new file mode 100644 index 0000000000..afb72674f6 --- /dev/null +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -0,0 +1,92 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.render-wasm.text-paste + "Restyles the text WASM just inserted from a paste fragment (see + `app.util.text.clipboard`) with each run's overrides." + (:require + [app.main.fonts :as fonts] + [app.render-wasm.text-editor :as text-editor] + [cuerdas.core :as str])) + +(defn fragment->text + [fragment] + (->> fragment + (map (fn [paragraph] (str/join (map :text (:children paragraph))))) + (str/join "\n"))) + +(defn styled? + [fragment] + (some (fn [paragraph] (some (comp seq :attrs) (:children paragraph))) fragment)) + +(defn- styled-ranges + "The content range of every run with overrides, for a fragment inserted at + `start` (`{:para :offset}`, offsets in UTF-16 units like WASM's)." + [fragment {:keys [para offset]}] + (for [[idx paragraph] (map-indexed vector fragment) + :let [para-idx (+ para idx) + runs (:children paragraph) + starts (reductions + (if (zero? idx) offset 0) + (map (comp count :text) runs))] + [run run-start] (map vector runs starts) + :when (seq (:attrs run))] + {:attrs (:attrs run) + :range {:start-para para-idx + :start-offset run-start + :end-para para-idx + :end-offset (+ run-start (count (:text run)))}})) + +(defn- target-weight + "The weight for `span` given a weight override. \"400\" means not bold, so it + only unbolds: a span below bold keeps its own weight." + [span font-weight] + (let [current (or (:font-weight span) "400")] + (if (and (= font-weight "400") (< (js/parseInt current 10) 600)) + current + (or font-weight current)))) + +(defn- resolve-overrides + "`span` with `overrides`, weight and italic resolved to a variant its font has. + A changed span leaves its typography, which it no longer matches." + [span {:keys [font-weight font-style] :as overrides}] + (let [variant (when (or font-weight font-style) + (some-> (fonts/get-font-data (:font-id span)) + (fonts/find-closest-variant + (target-weight span font-weight) + (or font-style (:font-style span) "normal")))) + result (cond-> (merge span (select-keys overrides [:text-decoration :text-transform])) + (some? variant) + (assoc :font-weight (:weight variant) + :font-style (:style variant) + :font-variant-id (:id variant)))] + (if (= result span) + span + (dissoc result :typography-ref-id :typography-ref-file)))) + +(defn fragment->content + "Penpot content for `fragment`, with `base` as the style its overrides go over." + [fragment base] + {:type "root" + :children + [{:type "paragraph-set" + :children + (mapv (fn [{:keys [children]}] + (merge base + {:type "paragraph" + :children (if (seq children) + (mapv (fn [{:keys [text attrs]}] + (resolve-overrides (assoc base :text text) attrs)) + children) + [(assoc base :text "")])})) + fragment)}]}) + +(defn apply-fragment-styles + "Restyles the `fragment` text WASM inserted at `start` in `content`." + [content fragment start] + (reduce (fn [content {:keys [range attrs]}] + (text-editor/apply-styles-over-range content range #(resolve-overrides % attrs))) + content + (styled-ranges fragment start))) diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs new file mode 100644 index 0000000000..963f49b688 --- /dev/null +++ b/frontend/src/app/util/text/clipboard.cljs @@ -0,0 +1,254 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.util.text.clipboard + "Reads clipboard data into paste fragments: `[{:attrs {} :children [{:text :attrs}]}]`, + where run attrs are emphasis overrides laid over the style at the caret." + (:require + [cuerdas.core :as str])) + +(def ^:private block-tags + #{"ADDRESS" "ARTICLE" "ASIDE" "BLOCKQUOTE" "CAPTION" "DD" "DETAILS" "DIV" "DL" + "DT" "FIELDSET" "FIGCAPTION" "FIGURE" "FOOTER" "FORM" "H1" "H2" "H3" "H4" + "H5" "H6" "HEADER" "HR" "LI" "MAIN" "NAV" "OL" "P" "PRE" "SECTION" + "SUMMARY" "TABLE" "TBODY" "TFOOT" "THEAD" "TR" "UL"}) + +;; Elements whose text is read; any other element is skipped with its children. +;; `O:P` is Word's paragraph filler, which marks its empty lines. +(def ^:private allowed-tags + (into block-tags + #{"A" "ABBR" "B" "BDI" "BDO" "BIG" "BR" "CENTER" "CITE" "CODE" "DATA" + "DEL" "DFN" "EM" "FONT" "I" "INS" "KBD" "LABEL" "MARK" "O:P" "Q" "S" + "SAMP" "SMALL" "SPAN" "STRIKE" "STRONG" "SUB" "SUP" "TD" "TH" "TIME" + "TT" "U" "VAR" "WBR"})) + +(def ^:private bold-tags #{"B" "STRONG" "TH" "H1" "H2" "H3" "H4" "H5" "H6"}) +(def ^:private italic-tags #{"I" "EM" "CITE" "VAR" "DFN"}) +(def ^:private underline-tags #{"U" "INS"}) +(def ^:private strike-tags #{"S" "STRIKE" "DEL"}) +(def ^:private cell-tags #{"TD" "TH"}) + +(defn- style-value + [^js element property] + (some-> (.-style element) (.getPropertyValue property) str/lower)) + +(defn- parse-bold + "True/false for a CSS font-weight, nil when it says nothing." + [weight] + (cond + (empty? weight) nil + (#{"bold" "bolder"} weight) true + (#{"normal" "lighter"} weight) false + :else (let [n (js/parseInt weight 10)] + (when-not (js/isNaN n) (>= n 600))))) + +(defn- parse-italic + [font-style] + (cond + (empty? font-style) nil + (or (= font-style "italic") (str/starts-with? font-style "oblique")) true + (= font-style "normal") false + :else nil)) + +(defn- display-block? + [display] + (some #(str/starts-with? display %) ["block" "list-item" "table" "flex" "grid"])) + +(defn- element-state + "The emphasis state inside `element`, or nil when hidden. Inline styles win over + tags, so Google Docs' `` wrapper is not bold." + [state ^js element tag] + (let [display (or (style-value element "display") "") + weight (parse-bold (style-value element "font-weight")) + italic (parse-italic (style-value element "font-style")) + decoration (str (style-value element "text-decoration-line") " " + (style-value element "text-decoration")) + transform (style-value element "text-transform") + space (style-value element "white-space")] + (when-not (= display "none") + (cond-> state + (contains? bold-tags tag) (assoc :bold? true) + (some? weight) (assoc :bold? weight) + (contains? italic-tags tag) (assoc :italic? true) + (some? italic) (assoc :italic? italic) + (contains? underline-tags tag) (assoc :underline? true) + (str/includes? decoration "underline") (assoc :underline? true) + (contains? strike-tags tag) (assoc :strike? true) + (str/includes? decoration "line-through") (assoc :strike? true) + (= tag "A") (assoc :link? true) + (= tag "PRE") (assoc :pre? true) + (seq space) (assoc :pre? (str/starts-with? space "pre")) + (= transform "none") (dissoc :transform) + (#{"uppercase" "lowercase" "capitalize"} transform) (assoc :transform transform))))) + +(defn- block-element? + [^js element tag] + (let [display (or (style-value element "display") "")] + (if (seq display) + (display-block? display) + (contains? block-tags tag)))) + +(defn- state->attrs + "Penpot text attrs for an emphasis state, with every attr set so the source + replaces the caret's emphasis. Link underlines go with the link." + [{:keys [bold? italic? underline? strike? link? transform]}] + (let [underline? (and underline? (not link?))] + {:font-weight (if bold? "700" "400") + :font-style (if italic? "italic" "normal") + :text-decoration (cond underline? "underline" strike? "line-through" :else "none") + :text-transform (or transform "none")})) + +;; --- Fragment builder +;; +;; `:runs` is the paragraph being built; `:space?` is true when the next +;; collapsible space would be dropped (paragraph start or after a space). + +(def ^:private empty-builder + {:paragraphs [] :runs [] :space? true}) + +(defn- trim-trailing-space + [runs] + (let [idx (dec (count runs)) + run (get runs idx)] + (if (and run (not (:pre? run))) + (update runs idx update :text str/rtrim " ") + runs))) + +(defn- close-paragraph + [{:keys [runs] :as builder}] + (-> builder + (update :paragraphs conj (trim-trailing-space runs)) + (assoc :runs [] :space? true))) + +(defn- soft-break + "Ends the paragraph at a block boundary, unless nothing was written yet." + [{:keys [runs] :as builder}] + (if (some #(seq (:text %)) runs) + (close-paragraph builder) + (assoc builder :runs []))) + +(defn- add-run + [builder text attrs pre?] + (update builder :runs conj {:text text :attrs attrs :pre? pre?})) + +(defn- add-collapsible-text + [{:keys [space?] :as builder} text attrs] + (let [text (cond-> (str/replace text #"[ \t\n\r\f]+" " ") + space? (str/ltrim " "))] + (if (empty? text) + builder + (-> builder + (add-run text attrs false) + (assoc :space? (str/ends-with? text " ")))))) + +(defn- add-pre-text + [builder text attrs] + (let [lines (.split (str/replace text "\r" "") "\n")] + (reduce (fn [builder [idx line]] + (cond-> builder + (pos? idx) (close-paragraph) + (seq line) (add-run line attrs true) + :always (assoc :space? false))) + builder + (map-indexed vector lines)))) + +(defn- add-text + [builder text state] + (let [attrs (state->attrs state)] + (if (:pre? state) + (add-pre-text builder text attrs) + (add-collapsible-text builder text attrs)))) + +(defn- walk-node + [builder ^js node state] + (case (.-nodeType node) + 3 (add-text builder (.-nodeValue node) state) + 1 (let [tag (str/upper (.-tagName node))] + (if-not (contains? allowed-tags tag) + builder + (if-let [state (element-state state node tag)] + (if (= tag "BR") + (close-paragraph builder) + (let [block? (block-element? node tag) + ;; Cells of a row are joined by a tab. + cell? (and (contains? cell-tags tag) + (some? (.-previousElementSibling node))) + builder (cond-> builder + block? (soft-break) + cell? (-> (add-run "\t" (state->attrs state) true) + (assoc :space? true))) + builder (reduce #(walk-node %1 %2 state) + builder + (array-seq (.-childNodes node)))] + (cond-> builder block? (soft-break)))) + builder))) + builder)) + +;; --- Normalizing + +(defn- merge-runs + "Joins neighbouring runs that share attrs and drops empty ones." + [runs] + (reduce (fn [acc {:keys [text attrs]}] + (let [prev (peek acc)] + (cond + (empty? text) acc + (= (:attrs prev) attrs) (conj (pop acc) (update prev :text str text)) + :else (conj acc {:text text :attrs attrs})))) + [] + runs)) + +(defn- runs->paragraph + [runs] + (let [runs (->> runs + (map #(update % :text str/replace " " " ")) + (merge-runs))] + {:attrs {} + :children (if (every? #(str/blank? (:text %)) runs) [] runs)})) + +(defn- empty-paragraph? + [paragraph] + (empty? (:children paragraph))) + +(defn- normalize-paragraphs + "Drops empty paragraphs at both ends and keeps at most one in a row." + [paragraphs] + (let [paragraphs (->> paragraphs + (drop-while empty-paragraph?) + (reverse) + (drop-while empty-paragraph?) + (reverse))] + (->> paragraphs + (partition-by empty-paragraph?) + (mapcat #(if (empty-paragraph? (first %)) [(first %)] %)) + (vec)))) + +(defn document->fragment + "The paste fragment for a parsed HTML document, or nil when it has no text." + [^js document] + (let [paragraphs (->> (array-seq (.-childNodes (.-body document))) + (reduce #(walk-node %1 %2 {}) empty-builder) + (soft-break) + :paragraphs + (map runs->paragraph) + (normalize-paragraphs))] + (when (seq paragraphs) + paragraphs))) + +(defn html->fragment + [html] + (-> (js/DOMParser.) + (.parseFromString html "text/html") + (document->fragment))) + +(defn text->fragment + "The paste fragment for plain text, one paragraph per line, or nil when empty." + [text] + (when (seq text) + (mapv (fn [line] + {:attrs {} + :children (if (empty? line) [] [{:text line :attrs {}}])}) + (.split (str/replace text "\r" "") "\n")))) diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs new file mode 100644 index 0000000000..7394d031a4 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -0,0 +1,146 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-wasm.text-paste-test + "Restyling pasted text. Test content already holds the pasted text in the + style of the span it went into, as after `text-editor-sync-content`." + (:require + [app.render-wasm.text-paste :as text-paste] + [cljs.test :as t :include-macros true])) + +(def ^:private regular + {:font-id "sourcesanspro" :font-variant-id "regular" + :font-weight "400" :font-style "normal"}) + +(defn- span + ([text] (span text {})) + ([text attrs] (merge regular attrs {:text text}))) + +(defn- content [& paragraphs] + {:type "root" + :children [{:type "paragraph-set" + :children (mapv (fn [spans] {:type "paragraph" :children spans}) + paragraphs)}]}) + +(defn- spans-of [content] + (mapv :children (-> content :children first :children))) + +(defn- fragment-paragraph [& runs] + {:attrs {} :children (vec runs)}) + +(defn- run + ([text] (run text {})) + ([text attrs] {:text text :attrs attrs})) + +(def ^:private bold-variant + {:font-weight "700" :font-style "normal" :font-variant-id "bold"}) + +(t/deftest fragment->text + (t/testing "paragraphs are joined by newlines" + (t/is (= "ab\n\nc" + (text-paste/fragment->text + [(fragment-paragraph (run "a") (run "b")) + (fragment-paragraph) + (fragment-paragraph (run "c"))]))))) + +(t/deftest bold-run-resolves-to-a-font-variant + (t/testing "a bold run inside a paragraph gets the bold variant of the span's font" + (let [pasted (content [(span "Hello big world")]) + fragment [(fragment-paragraph (run "big " {:font-weight "700"}))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 6})] + (t/is (= [[(span "Hello ") (span "big " bold-variant) (span "world")]] + (spans-of result)))))) + +(t/deftest italic-run-keeps-the-span-weight + (t/testing "italic over a bold span picks the bold italic variant" + (let [pasted (content [(span "x" bold-variant)]) + fragment [(fragment-paragraph (run "x" {:font-style "italic"}))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})] + (t/is (= [[(span "x" {:font-weight "700" :font-style "italic" + :font-variant-id "bolditalic"})]] + (spans-of result)))))) + +(t/deftest html-emphasis-replaces-the-caret-emphasis + (let [no-emphasis {:font-weight "400" :font-style "normal" + :text-decoration "none" :text-transform "none"}] + (t/testing "plain source text pasted into bold, underlined text comes out plain" + (let [pasted (content [(span "aXb" (assoc bold-variant :text-decoration "underline"))]) + fragment [(fragment-paragraph (run "X" no-emphasis))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 1})] + (t/is (= [[(span "a" (assoc bold-variant :text-decoration "underline")) + (span "X" {:text-decoration "none" :text-transform "none"}) + (span "b" (assoc bold-variant :text-decoration "underline"))]] + (spans-of result))))) + + (t/testing "not bold leaves a light weight as it is" + (let [light {:font-weight "300" :font-style "normal" :font-variant-id "300"} + pasted (content [(span "X" light)]) + fragment [(fragment-paragraph (run "X" no-emphasis))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})] + (t/is (= [[(span "X" (assoc light :text-decoration "none" :text-transform "none"))]] + (spans-of result))))))) + +(t/deftest unknown-font-keeps-its-weight + (t/testing "a font we have no data for cannot resolve a variant, so it stays" + (let [unknown (span "x" {:font-id "missing-font"}) + fragment [(fragment-paragraph (run "x" {:font-weight "700"}))] + result (text-paste/apply-fragment-styles (content [unknown]) fragment {:para 0 :offset 0})] + (t/is (= [[unknown]] (spans-of result)))))) + +(t/deftest typography-link + (let [linked {:typography-ref-id "typo-id" :typography-ref-file "file-id"}] + (t/testing "a span changed by the paste leaves its typography" + (let [fragment [(fragment-paragraph (run "x" {:text-decoration "underline"}))] + result (text-paste/apply-fragment-styles + (content [(span "x" linked)]) fragment {:para 0 :offset 0})] + (t/is (= [[(span "x" {:text-decoration "underline"})]] + (spans-of result))))) + + (t/testing "a span the paste leaves as it was keeps its typography" + (let [already (span "x" (merge bold-variant linked)) + fragment [(fragment-paragraph (run "x" {:font-weight "700"}))] + result (text-paste/apply-fragment-styles (content [already]) fragment {:para 0 :offset 0})] + (t/is (= [[already]] (spans-of result))))))) + +(t/deftest ranges-across-paragraphs + (t/testing "runs after the first paragraph start at offset 0 of their paragraph" + (let [pasted (content [(span "abX")] [(span "Ycd")]) + fragment [(fragment-paragraph (run "X" {:font-weight "700"})) + (fragment-paragraph (run "Y" {:font-weight "700"}))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})] + (t/is (= [[(span "ab") (span "X" bold-variant)] + [(span "Y" bold-variant) (span "cd")]] + (spans-of result)))))) + +(t/deftest ranges-count-utf16-units + (t/testing "an emoji in the fragment moves later runs by its two UTF-16 units" + (let [pasted (content [(span "😀b")]) + fragment [(fragment-paragraph (run "😀") (run "b" {:font-weight "700"}))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})] + (t/is (= [[(span "😀") (span "b" bold-variant)]] + (spans-of result))))) + + (t/testing "a paste after an emoji starts at the UTF-16 offset WASM reports" + (let [pasted (content [(span "😀Xb")]) + fragment [(fragment-paragraph (run "X" {:font-weight "700"}))] + result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})] + (t/is (= [[(span "😀") (span "X" bold-variant) (span "b")]] + (spans-of result)))))) + +(t/deftest fragment->content + (t/testing "runs become spans over the base style, and empty paragraphs keep one empty span" + (let [result (text-paste/fragment->content + [(fragment-paragraph (run "Hello ") (run "bold" {:font-weight "700"})) + (fragment-paragraph)] + regular)] + (t/is (= [[(span "Hello ") (span "bold" bold-variant)] + [(span "")]] + (spans-of result))))) + + (t/testing "paragraphs carry the base style" + (let [result (text-paste/fragment->content [(fragment-paragraph (run "x"))] regular)] + (t/is (= (assoc regular :type "paragraph") + (dissoc (-> result :children first :children first) :children)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 349cdfd029..6c0c436df0 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -85,6 +85,7 @@ [frontend-tests.render-wasm.serialization-test] [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] + [frontend-tests.render-wasm.text-paste-test] [frontend-tests.router-test] [frontend-tests.svg-fills-test] [frontend-tests.svg-filters-test] @@ -123,6 +124,7 @@ [frontend-tests.util-queue-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] + [frontend-tests.util-text-clipboard-test] [frontend-tests.util-text-editor-test] [frontend-tests.util-webapi-test] [frontend-tests.util-zip-test] @@ -222,6 +224,7 @@ 'frontend-tests.render-wasm.serialization-test 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test + 'frontend-tests.render-wasm.text-paste-test 'frontend-tests.router-test 'frontend-tests.svg-fills-test 'frontend-tests.svg-filters-test @@ -261,6 +264,7 @@ 'frontend-tests.util-queue-test 'frontend-tests.util-range-tree-test 'frontend-tests.util-simple-math-test + 'frontend-tests.util-text-clipboard-test 'frontend-tests.util-text-editor-test 'frontend-tests.util-webapi-test 'frontend-tests.util.dom.dnd-test diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs new file mode 100644 index 0000000000..a8f65d6e4f --- /dev/null +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -0,0 +1,165 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.util-text-clipboard-test + "Clipboard HTML and plain text read into paste fragments. Only emphasis + survives; the documents come from jsdom since the runner has no DOM." + (:require + ["jsdom" :refer [JSDOM]] + [app.util.text.clipboard :as clipboard] + [cljs.test :as t :include-macros true])) + +(defn- html->fragment + [html] + (let [window (.-window (JSDOM. "")) + parser (new (.-DOMParser window))] + (clipboard/document->fragment (.parseFromString parser html "text/html")))) + +(defn- paragraph [& runs] + {:attrs {} :children (vec runs)}) + +;; HTML runs carry every emphasis attr, so the source decides it. +(def ^:private no-emphasis + {:font-weight "400" :font-style "normal" :text-decoration "none" :text-transform "none"}) + +(defn- run + ([text] (run text {})) + ([text attrs] {:text text :attrs (merge no-emphasis attrs)})) + +(defn- plain-run + [text] + {:text text :attrs {}}) + +(def ^:private bold {:font-weight "700"}) +(def ^:private italic {:font-style "italic"}) + +(def ^:private google-docs-html + (str "" + "" + "

" + "Hello " + "bold" + " and italic" + "


" + "

" + "Second" + "

")) + +(def ^:private word-html + (str "" + "" + "" + "

Plain bold

\n" + "

 

\n" + "

italic

" + "")) + +(def ^:private web-page-html + (str "" + "

Title

" + "

" + "Some strong and " + "a link.

" + "
    \n
  • One
  • \n
  • Two
  • \n
")) + +(t/deftest google-docs-keeps-emphasis + (t/testing "the font-weight:normal wrapper does not make the whole paste bold" + (t/is (= [(paragraph (run "Hello ") (run "bold" bold) (run " and italic" italic)) + (paragraph) + (paragraph (run "Second"))] + (html->fragment google-docs-html))))) + +(t/deftest word-keeps-emphasis + (t/testing "Word markup and its empty   paragraphs map to plain paragraphs" + (t/is (= [(paragraph (run "Plain ") (run "bold" bold)) + (paragraph) + (paragraph (run "italic" italic))] + (html->fragment word-html))))) + +(t/deftest web-page-drops-fonts-colors-links-and-list-markers + (t/testing "headings are bold, links lose their underline, list items become paragraphs" + (t/is (= [(paragraph (run "Title" bold)) + (paragraph (run "Some ") (run "strong" bold) (run " and a link.")) + (paragraph (run "One")) + (paragraph (run "Two"))] + (html->fragment web-page-html))))) + +(t/deftest font-weight-threshold + (t/testing "600 and above is bold" + (t/is (= [(paragraph (run "semi" bold))] + (html->fragment "semi")))) + + (t/testing "light weights are ignored" + (t/is (= [(paragraph (run "light"))] + (html->fragment "light"))))) + +(t/deftest decoration-and-transform + (t/testing "underline wins over line-through, as a span holds one decoration" + (t/is (= [(paragraph (run "both" {:text-decoration "underline"}))] + (html->fragment "both")))) + + (t/testing "line-through alone is kept" + (t/is (= [(paragraph (run "gone" {:text-decoration "line-through"}))] + (html->fragment "gone")))) + + (t/testing "text-transform is kept" + (t/is (= [(paragraph (run "loud" {:text-transform "uppercase"}))] + (html->fragment "loud"))))) + +(t/deftest whitespace + (t/testing "collapsible whitespace collapses and is trimmed at line ends" + (t/is (= [(paragraph (run "a b"))] + (html->fragment "

a \n b

")))) + + (t/testing "  is kept as a space" + (t/is (= [(paragraph (run "a b"))] + (html->fragment "

a  b

")))) + + (t/testing "pre keeps spaces and splits lines into paragraphs" + (t/is (= [(paragraph (run "line 1")) + (paragraph (run " line 2"))] + (html->fragment "
line 1\n  line 2
"))))) + +(t/deftest line-breaks-and-empty-paragraphs + (t/testing "br starts a new paragraph" + (t/is (= [(paragraph (run "a")) (paragraph (run "b"))] + (html->fragment "a
b")))) + + (t/testing "runs of empty lines collapse into one" + (t/is (= [(paragraph (run "a")) (paragraph) (paragraph (run "b"))] + (html->fragment "

a




b

")))) + + (t/testing "empty lines at both ends are dropped" + (t/is (= [(paragraph (run "a"))] + (html->fragment "

a



"))))) + +(t/deftest tables + (t/testing "each row is a paragraph with its cells joined by a tab" + (t/is (= [(paragraph (run "a\tb")) (paragraph (run "c\td"))] + (html->fragment + "
a b
cd
"))))) + +(t/deftest hidden-content-is-skipped + (t/testing "style, script and display:none give no text" + (t/is (= [(paragraph (run "shown"))] + (html->fragment + "

shown

hidden

")))) + + (t/testing "elements outside the allowlist are skipped with their text" + (t/is (= [(paragraph (run "kept"))] + (html->fragment + "

keptcustom

")))) + + (t/testing "HTML without text gives no fragment" + (t/is (nil? (html->fragment ""))))) + +(t/deftest plain-text + (t/testing "each line is a paragraph and CRLF counts as one break" + (t/is (= [(paragraph (plain-run "a")) (paragraph) (paragraph (plain-run "b"))] + (clipboard/text->fragment "a\r\n\r\nb")))) + + (t/testing "empty text gives no fragment" + (t/is (nil? (clipboard/text->fragment ""))))) From b06a43404bd4469ac0c507f9e41c189e9af4b2d3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 5 Oct 2026 11:32:29 +0200 Subject: [PATCH 024/325] :tada: Add detach to typography and shadow tokens (#11969) * :tada: Add detach to typography tokens * :tada: Add detach to shadow tokens * :tada: Add tests --- .../ui/specs/tokens/detach-reference.spec.js | 194 ++++++++++++++++++ .../management/forms/controls/utils.cljs | 27 +++ .../tokens/management/forms/shadow.cljs | 109 +++++++--- .../tokens/management/forms/shadow.scss | 9 + .../tokens/management/forms/typography.cljs | 144 +++++++++---- .../tokens/management/forms/typography.scss | 9 + frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/referenced_token_value_test.cljs | 55 +++++ frontend/translations/en.po | 18 +- frontend/translations/es.po | 18 +- 10 files changed, 500 insertions(+), 85 deletions(-) create mode 100644 frontend/playwright/ui/specs/tokens/detach-reference.spec.js create mode 100644 frontend/test/frontend_tests/tokens/referenced_token_value_test.cljs diff --git a/frontend/playwright/ui/specs/tokens/detach-reference.spec.js b/frontend/playwright/ui/specs/tokens/detach-reference.spec.js new file mode 100644 index 0000000000..b56f14f655 --- /dev/null +++ b/frontend/playwright/ui/specs/tokens/detach-reference.spec.js @@ -0,0 +1,194 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; +import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage"; +import { + setupEmptyTokensFileRender, + setupTypographyTokensFileRender, + unfoldTokenType, +} from "./helpers"; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); + await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json"); +}); + +const openEditModal = async (page, tokensTabPanel, name) => { + await tokensTabPanel + .getByRole("button", { name, exact: true }) + .click({ button: "right" }); + await page.getByText("Edit token").click(); +}; + +// Hovers the reference input and clicks the detach button, which is only +// visible while the reference row is hovered or focused. +const detachReference = async (modal) => { + const referenceField = modal.getByRole("textbox", { name: "Reference" }); + const detachButton = modal.getByRole("button", { name: "Detach reference" }); + + await expect(referenceField).toBeVisible(); + await expect(detachButton).toBeHidden(); + + await referenceField.hover(); + await expect(detachButton).toBeVisible(); + await detachButton.click(); +}; + +test.describe("Tokens - detach reference", () => { + test("User detaches a typography reference and edits the copied values", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokensSidebar } = + await setupTypographyTokensFileRender(page); + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + const saveButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + + // Create a typography token that references "Full" from the fixture. + await tokensTabPanel + .getByRole("button", { name: "Add Token: Typography" }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + await tokensUpdateCreateModal.getByLabel("Name").fill("detached"); + await tokensUpdateCreateModal.getByTestId("reference-opt").click(); + await tokensUpdateCreateModal + .getByRole("textbox", { name: "Reference" }) + .fill("{Full}"); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + await unfoldTokenType(tokensSidebar, "typography"); + await openEditModal(page, tokensTabPanel, "detached"); + await expect(tokensUpdateCreateModal).toBeVisible(); + + await detachReference(tokensUpdateCreateModal); + + // The values of "Full" are now in the composite tab. + const fontFamilyField = tokensUpdateCreateModal + .getByRole("textbox", { name: "Font family" }) + .first(); + const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Font Size", + }); + + await expect(fontFamilyField).toHaveValue("42dot Sans"); + await expect(fontSizeField).toHaveValue("100"); + await expect( + tokensUpdateCreateModal.getByRole("textbox", { name: "Font Weight" }), + ).toHaveValue("300"); + await expect( + tokensUpdateCreateModal.getByRole("textbox", { name: "Line Height" }), + ).toHaveValue("2"); + await expect( + tokensUpdateCreateModal.getByRole("textbox", { name: "Letter Spacing" }), + ).toHaveValue("2"); + await expect( + tokensUpdateCreateModal.getByRole("textbox", { name: "Text Case" }), + ).toHaveValue("uppercase"); + await expect( + tokensUpdateCreateModal.getByRole("textbox", { name: "Text Decoration" }), + ).toHaveValue("underline"); + + await fontSizeField.fill("24"); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + // The detached token keeps its own values. + await openEditModal(page, tokensTabPanel, "detached"); + await expect(fontSizeField).toHaveValue("24"); + await tokensUpdateCreateModal + .getByRole("button", { name: "Cancel" }) + .click(); + + // The referenced token is not modified. + await openEditModal(page, tokensTabPanel, "Full"); + await expect(fontSizeField).toHaveValue("100"); + }); + + test("User detaches a shadow reference and edits the copied values", async ({ + page, + }) => { + const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + const addShadowButton = tokensTabPanel.getByRole("button", { + name: "Add Token: Shadow", + }); + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + const colorField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Color", + }); + const offsetXField = tokensUpdateCreateModal.getByRole("textbox", { + name: "X", + }); + const offsetYField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Y", + }); + const blurField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Blur", + }); + const spreadField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Spread", + }); + const saveButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + + // Base shadow token. + await addShadowButton.click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + await nameField.fill("base-shadow"); + await colorField.fill("red"); + await offsetXField.fill("2"); + await offsetYField.fill("3"); + await blurField.fill("5"); + await spreadField.fill("1"); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + // Shadow token that references the base one. + await addShadowButton.click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + await nameField.fill("alias-shadow"); + await tokensUpdateCreateModal.getByTestId("reference-opt").click(); + await tokensUpdateCreateModal + .getByRole("textbox", { name: "Reference" }) + .fill("{base-shadow}"); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + await unfoldTokenType(tokensTabPanel, "shadow"); + await openEditModal(page, tokensTabPanel, "alias-shadow"); + await expect(tokensUpdateCreateModal).toBeVisible(); + + await detachReference(tokensUpdateCreateModal); + + // The values of "base-shadow" are now in the shadow tab. + await expect(colorField).toHaveValue("red"); + await expect(offsetXField).toHaveValue("2"); + await expect(offsetYField).toHaveValue("3"); + await expect(blurField).toHaveValue("5"); + await expect(spreadField).toHaveValue("1"); + + await offsetXField.fill("10"); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + // The detached token keeps its own values. + await openEditModal(page, tokensTabPanel, "alias-shadow"); + await expect(offsetXField).toHaveValue("10"); + await tokensUpdateCreateModal + .getByRole("button", { name: "Cancel" }) + .click(); + + // The referenced token is not modified. + await openEditModal(page, tokensTabPanel, "base-shadow"); + await expect(offsetXField).toHaveValue("2"); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs index 65bd7787c6..75e2317814 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs @@ -125,3 +125,30 @@ [error token-name] (let [translated-string (tr "errors.tokens.name-collision" token-name)] (= error translated-string))) + +(defn- referenced-token-name + "Name of the token `reference` points to, when `reference` is exactly + one `{name}` reference." + [reference] + (when (string? reference) + (let [reference (str/trim reference) + names (cto/find-token-value-references reference) + name (first names)] + (when (and (= 1 (count names)) + (= reference (str "{" name "}"))) + name)))) + +(defn referenced-token-value + "Composite value of the `token-type` token that `reference` points to, + following chained references. Nil when the chain does not end in a + `token-type` token with a composite value." + [tokens reference token-type] + (loop [reference reference + visited #{}] + (when-let [name (referenced-token-name reference)] + (let [{:keys [type value]} (get tokens name)] + (when (and (= token-type type) + (not (contains? visited name))) + (if (string? value) + (recur value (conj visited name)) + value)))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 90fef836e7..2d3807b57a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -18,6 +18,7 @@ [app.main.ui.forms :as forms] [app.main.ui.hooks :as hooks] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-self-reference default-validate-token]] [app.util.dom :as dom] @@ -75,6 +76,25 @@ :blur "4" :spread "0"}) +(defn- make-default-value + [value] + (cond + (string? value) + {:reference value + :shadow []} + + (vector? value) + {:reference nil + :shadow (mapv (fn [shadow] + (-> shadow + (update :blur #(if (str/blank? %) "0" %)) + (update :spread #(if (str/blank? %) "0" %)))) + value)} + + :else + {:reference nil + :shadow [default-token-shadow]})) + (defn get-subtoken [token index prop value-subfield] (let [value (get-in token [:value value-subfield index prop])] @@ -202,19 +222,66 @@ :show-button (> length 1)}]))) (mf/defc reference-form* - [{:keys [token tokens] :as props}] - [:div {:class (stl/css :input-row-reference)} - [:> token.controls/input-composite* - {:placeholder (tr "workspace.tokens.reference-composite-shadow") - :aria-label (tr "labels.reference") - :icon i/drop-shadow - :name :reference - :token token - :tokens tokens}]]) + [{:keys [token tokens on-detach] :as props}] + (let [form (mf/use-ctx forms/context) + reference (get-in @form [:data :value :reference]) + + detached-value + (mf/with-memo [tokens reference] + (csu/referenced-token-value tokens reference :shadow)) + + on-detach-click + (mf/use-fn + (mf/deps detached-value on-detach) + (fn [event] + (dom/prevent-default event) + (on-detach detached-value)))] + + [:div {:class (stl/css :input-row-reference :reference-row)} + [:> token.controls/input-composite* + {:placeholder (tr "workspace.tokens.reference-composite-shadow") + :aria-label (tr "labels.reference") + :icon i/drop-shadow + :name :reference + :token token + :tokens tokens + :slot-end (when (some? detached-value) + (mf/html + [:> icon-button* {:variant "action" + :type "button" + :class (stl/css :detach-button) + :aria-label (tr "workspace.tokens.detach-reference") + :on-click on-detach-click + :icon i/detach}]))}]])) (mf/defc tabs-wrapper* [{:keys [token tokens tab handle-toggle value-subfield] :rest props}] (let [form (mf/use-ctx forms/context) + + ;; Form value of the detached reference, used so the shadow + ;; inputs show the resolved value of the copied fields. + detached* (mf/use-state nil) + detached (deref detached*) + + composite-token + (mf/with-memo [token detached] + (if (some? detached) + (assoc token :value detached) + token)) + + on-detach + (mf/use-fn + (mf/deps form handle-toggle) + (fn [value] + (let [value (make-default-value value)] + (swap! form (fn [state] + (-> state + (assoc-in [:data :value] value) + (update :errors dissoc :value) + (update :extra-errors dissoc :value)))) + (reset! detached* value) + (handle-toggle "composite")))) + on-add-shadow-block (mf/use-fn (mf/deps value-subfield) @@ -250,13 +317,14 @@ :id "reference-opt"}]]] (if (= tab :composite) - [:> composite-form* {:token token + [:> composite-form* {:token composite-token :tokens tokens :remove-shadow-block remove-shadow-block :value-subfield value-subfield}] [:> reference-form* {:token token - :tokens tokens}])])) + :tokens tokens + :on-detach on-detach}])])) ;; TODO: use cfo/make-schema:token-value and extend it with shadow and reference fields (defn- make-schema @@ -329,25 +397,6 @@ (or ref-valid? valid-composite-shadow?)))]])) -(defn- make-default-value - [value] - (cond - (string? value) - {:reference value - :shadow []} - - (vector? value) - {:reference nil - :shadow (mapv (fn [shadow] - (-> shadow - (update :blur #(if (str/blank? %) "0" %)) - (update :spread #(if (str/blank? %) "0" %)))) - value)} - - :else - {:reference nil - :shadow [default-token-shadow]})) - (mf/defc form* [{:keys [token token-type diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss index ead3c104fe..f3a7047749 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss @@ -32,6 +32,15 @@ padding-inline-start: var(--sp-m); } +.detach-button { + visibility: hidden; +} + +.reference-row:hover .detach-button, +.reference-row:focus-within .detach-button { + visibility: visible; +} + .title-bar { display: grid; grid-template-columns: 1fr auto auto; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index d23a9a1543..a34dced97c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -13,10 +13,14 @@ [app.common.types.token :as cto] [app.main.data.workspace.tokens.errors :as wte] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.forms :as forms] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]] + [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -60,6 +64,25 @@ check-typography-token-self-reference]) (default-validate-token)))) +;; HELPERS + +(def ^:private composite-keys + [:font-family + :font-size + :font-weight + :line-height + :letter-spacing + :text-case + :text-decoration]) + +(defn- composite-value->form-value + "Form data for the composite tab from a typography token map `value`." + [value] + (-> (cond-> value + (:font-family value) + (update :font-family cto/join-font-family)) + (select-keys composite-keys))) + ;; COMPONENTS (mf/defc composite-form* @@ -172,40 +195,88 @@ :tokens tokens}]]])) (mf/defc reference-form* - [{:keys [token tokens] :as props}] - [:div {:class (stl/css :input-row)} - [:> token.controls/input-composite* - {:placeholder (tr "workspace.tokens.reference-composite") - :aria-label (tr "labels.reference") - :icon i/text-typography - :name :reference - :token token - :tokens tokens}]]) + [{:keys [token tokens on-detach] :as props}] + (let [form (mf/use-ctx forms/context) + reference (get-in @form [:data :value :reference]) + + detached-value + (mf/with-memo [tokens reference] + (csu/referenced-token-value tokens reference :typography)) + + on-detach-click + (mf/use-fn + (mf/deps detached-value on-detach) + (fn [event] + (dom/prevent-default event) + (on-detach detached-value)))] + + [:div {:class (stl/css :input-row :reference-row)} + [:> token.controls/input-composite* + {:placeholder (tr "workspace.tokens.reference-composite") + :aria-label (tr "labels.reference") + :icon i/text-typography + :name :reference + :token token + :tokens tokens + :slot-end (when (some? detached-value) + (mf/html + [:> icon-button* {:variant "action" + :type "button" + :class (stl/css :detach-button) + :aria-label (tr "workspace.tokens.detach-reference") + :on-click on-detach-click + :icon i/detach}]))}]])) (mf/defc tabs-wrapper* [{:keys [token tokens tab handle-toggle] :rest props}] - [:* - [:div {:class (stl/css :title-bar)} - [:div {:class (stl/css :title)} (tr "labels.typography")] - [:& radio-buttons {:class (stl/css :listing-options) - :selected (d/name tab) - :on-change handle-toggle - :name "reference-composite-tab"} - [:& radio-button {:icon i/layers - :value "composite" - :title (tr "workspace.tokens.individual-tokens") - :id "composite-opt"}] - [:& radio-button {:icon i/tokens - :value "reference" - :title (tr "workspace.tokens.use-reference") - :id "reference-opt"}]]] - [:div {:class (stl/css :inputs-wrapper)} - (if (= tab :composite) - [:> composite-form* {:token token - :tokens tokens}] + (let [form (mf/use-ctx forms/context) - [:> reference-form* {:token token - :tokens tokens}])]]) + ;; Raw value of the detached reference, used so the composite + ;; inputs show the resolved value of the copied fields. + detached* (mf/use-state nil) + detached (deref detached*) + + composite-token + (mf/with-memo [token detached] + (if (some? detached) + (assoc token :value detached) + token)) + + on-detach + (mf/use-fn + (mf/deps form handle-toggle) + (fn [value] + (swap! form (fn [state] + (-> state + (assoc-in [:data :value] (composite-value->form-value value)) + (update :errors dissoc :value) + (update :extra-errors dissoc :value)))) + (reset! detached* value) + (handle-toggle "composite")))] + + [:* + [:div {:class (stl/css :title-bar)} + [:div {:class (stl/css :title)} (tr "labels.typography")] + [:& radio-buttons {:class (stl/css :listing-options) + :selected (d/name tab) + :on-change handle-toggle + :name "reference-composite-tab"} + [:& radio-button {:icon i/layers + :value "composite" + :title (tr "workspace.tokens.individual-tokens") + :id "composite-opt"}] + [:& radio-button {:icon i/tokens + :value "reference" + :title (tr "workspace.tokens.use-reference") + :id "reference-opt"}]]] + [:div {:class (stl/css :inputs-wrapper)} + (if (= tab :composite) + [:> composite-form* {:token composite-token + :tokens tokens}] + + [:> reference-form* {:token token + :tokens tokens + :on-detach on-detach}])]])) ;; SCHEMA @@ -274,17 +345,8 @@ {:reference value} (map? value) - (let [value (cond-> value - (:font-family value) - (update :font-family cto/join-font-family))] - (select-keys value - [:font-family - :font-size - :font-weight - :line-height - :letter-spacing - :text-case - :text-decoration])) + (composite-value->form-value value) + :else {})] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss index 2aa3e9707c..6fc73253bc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss @@ -23,6 +23,15 @@ gap: var(--sp-xs); } +.detach-button { + visibility: hidden; +} + +.reference-row:hover .detach-button, +.reference-row:focus-within .detach-button { + visibility: visible; +} + .title-bar { display: grid; grid-template-columns: 1fr auto; diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 6c0c436df0..413f40cc08 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -97,6 +97,7 @@ [frontend-tests.tokens.logic.token-data-test] [frontend-tests.tokens.logic.token-remapping-test] [frontend-tests.tokens.logic.tokens-status-test] + [frontend-tests.tokens.referenced-token-value-test] [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.workspace-tokens-remap-test] @@ -234,6 +235,7 @@ 'frontend-tests.tokens.logic.token-actions-test 'frontend-tests.tokens.logic.token-data-test 'frontend-tests.tokens.logic.token-remapping-test + 'frontend-tests.tokens.referenced-token-value-test 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.logic.tokens-status-test diff --git a/frontend/test/frontend_tests/tokens/referenced_token_value_test.cljs b/frontend/test/frontend_tests/tokens/referenced_token_value_test.cljs new file mode 100644 index 0000000000..d31928b01c --- /dev/null +++ b/frontend/test/frontend_tests/tokens/referenced_token_value_test.cljs @@ -0,0 +1,55 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.referenced-token-value-test + (:require + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] + [cljs.test :as t :include-macros true])) + +(def ^:private typography-value + {:font-family ["Inter"] + :font-size "16" + :font-weight "600"}) + +(def ^:private shadow-value + [{:offset-x "4" :offset-y "4" :blur "8" :spread "0" :color "#000000" :inset false} + {:offset-x "0" :offset-y "1" :blur "2" :spread "0" :color "#00000033" :inset true}]) + +(def ^:private tokens + {"base" {:name "base" :type :typography :value typography-value} + "alias" {:name "alias" :type :typography :value "{base}"} + "loop-a" {:name "loop-a" :type :typography :value "{loop-b}"} + "loop-b" {:name "loop-b" :type :typography :value "{loop-a}"} + "font-size" {:name "font-size" :type :font-size :value "16"} + "shadow-base" {:name "shadow-base" :type :shadow :value shadow-value} + "shadow-alias" {:name "shadow-alias" :type :shadow :value "{shadow-base}"}}) + +(t/deftest referenced-typography-value + (t/testing "returns the value of the referenced typography token" + (t/is (= typography-value (csu/referenced-token-value tokens "{base}" :typography))) + (t/is (= typography-value (csu/referenced-token-value tokens " {base} " :typography)))) + + (t/testing "follows chained references" + (t/is (= typography-value (csu/referenced-token-value tokens "{alias}" :typography)))) + + (t/testing "returns nil when the reference cannot be detached" + (t/is (nil? (csu/referenced-token-value tokens "{missing}" :typography))) + (t/is (nil? (csu/referenced-token-value tokens "{font-size}" :typography))) + (t/is (nil? (csu/referenced-token-value tokens "{loop-a}" :typography))) + (t/is (nil? (csu/referenced-token-value tokens "{base} {alias}" :typography))) + (t/is (nil? (csu/referenced-token-value tokens "base" :typography))) + (t/is (nil? (csu/referenced-token-value tokens nil :typography))))) + +(t/deftest referenced-shadow-value + (t/testing "returns the value of the referenced shadow token" + (t/is (= shadow-value (csu/referenced-token-value tokens "{shadow-base}" :shadow)))) + + (t/testing "follows chained references" + (t/is (= shadow-value (csu/referenced-token-value tokens "{shadow-alias}" :shadow)))) + + (t/testing "returns nil for a token of another type" + (t/is (nil? (csu/referenced-token-value tokens "{base}" :shadow))) + (t/is (nil? (csu/referenced-token-value tokens "{shadow-base}" :typography))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f51ef64459..c38fce16ae 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -3403,7 +3403,7 @@ msgstr "Themes" msgid "labels.tutorials" msgstr "Tutorials" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "labels.typography" msgstr "Typography" @@ -8711,6 +8711,10 @@ msgstr "Delete token" msgid "workspace.tokens.delete-theme-title" msgstr "Delete theme" +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 +msgid "workspace.tokens.detach-reference" +msgstr "Detach reference" + #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:430 msgid "workspace.tokens.duplicate" msgstr "Duplicate token" @@ -8771,7 +8775,7 @@ msgstr "Preview:" msgid "workspace.tokens.export.single-file" msgstr "Single file" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.font-size-value-enter" msgstr "Font size or {alias}" @@ -8855,11 +8859,11 @@ msgstr "Theme" msgid "workspace.tokens.label.theme-placeholder" msgstr "Add a theme (i.e. Light)" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.letter-spacing-value-enter-composite" msgstr "Letter spacing or {alias}" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.line-height-value-enter" msgstr "Line height (multiplier, px, %) or {alias}" @@ -8951,7 +8955,7 @@ msgstr "Radius" msgid "workspace.tokens.ref-not-valid" msgstr "Reference in {%s} is not valid or is not in any active set." -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.reference-composite" msgstr "Enter a token typography alias" @@ -9105,11 +9109,11 @@ msgstr "Description" msgid "workspace.tokens.token-font-family-select" msgstr "Select font family" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.token-font-family-value" msgstr "Font family" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.token-font-family-value-enter" msgstr "Font family or list of fonts separated by comma (,)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index a45e2512dd..00ea23e0b5 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -3440,7 +3440,7 @@ msgstr "Temas" msgid "labels.tutorials" msgstr "Tutoriales" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "labels.typography" msgstr "Tipografía" @@ -8783,6 +8783,10 @@ msgstr "Eliminar token" msgid "workspace.tokens.delete-theme-title" msgstr "Borrar theme" +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 +msgid "workspace.tokens.detach-reference" +msgstr "Desvincular referencia" + #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:430 msgid "workspace.tokens.duplicate" msgstr "Duplicar token" @@ -8845,7 +8849,7 @@ msgstr "Previsualizar:" msgid "workspace.tokens.export.single-file" msgstr "fichero único" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.font-size-value-enter" msgstr "Font size o {alias}" @@ -8933,11 +8937,11 @@ msgstr "Tema" msgid "workspace.tokens.label.theme-placeholder" msgstr "Añade un Tema (p. ej. Claro)" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.letter-spacing-value-enter-composite" msgstr "Letter spacing o {alias}" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.line-height-value-enter" msgstr "Line height (multiplicador, px o %) o {alias}" @@ -9031,7 +9035,7 @@ msgstr "Radio" msgid "workspace.tokens.ref-not-valid" msgstr "La referencia en {%s} no es válida o no se encuentra en ningún set activo." -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.reference-composite" msgstr "Introduce un alias de tipografía de token" @@ -9187,11 +9191,11 @@ msgstr "Descripción" msgid "workspace.tokens.token-font-family-select" msgstr "Seleccionar familia tipográfica" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.token-font-family-value" msgstr "Familia tipográfica" -#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:253, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:226 msgid "workspace.tokens.token-font-family-value-enter" msgstr "Familia tipográfica o lista de fuentes separadas por coma (,)" From aed4e12c63ef7f04cdc959748b8c0a48dd65dc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 5 Oct 2026 11:34:10 +0200 Subject: [PATCH 025/325] :zap: Avoid decoding all pages when building file thumbnail data (#12054) --- .../src/app/rpc/commands/files_thumbnails.clj | 82 +++++++++++-------- backend/test/backend_tests/rpc_file_test.clj | 67 +++++++++++++++ 2 files changed, 114 insertions(+), 35 deletions(-) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 1a821c4027..1ff4e46d40 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -15,10 +15,12 @@ [app.common.schema :as sm] [app.common.thumbnails :as thc] [app.common.time :as ct] + [app.common.types.objects-map :as omap] [app.common.types.shape-tree :as ctt] [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] + [app.features.fdata :as fdata] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.media.validation :as media.v] @@ -92,26 +94,38 @@ ;; --- COMMAND QUERY: get-file-data-for-thumbnail -;; We need to improve how we set frame for thumbnail in order to avoid -;; loading all pages into memory for find the frame set for thumbnail. +(defn- get-thumbnail-frame-candidates + "Only decode the objects whose encoded form can carry the flag." + [objects] + (if (omap/objects-map? objects) + (keep (fn [[id encoded]] + (when (str/includes? encoded "use-for-thumbnail") + (get objects id))) + (omap/get-data objects)) + (vals objects))) + +(defn- get-thumbnail-frame + "Find the frame marked to be used as thumbnail, loading pages lazily. + The returned frame has the :page-id assoc'ed." + [data] + (d/seek some? + (for [page (-> data :pages-index vals) + :let [page (cond-> page (pmap/pointer-map? page) deref)]] + (some-> (d/seek #(and (cfh/frame-shape? %) + (or (:use-for-thumbnail %) + (:use-for-thumbnail? %))) ; NOTE: backward comp (remove on v1.21) + (get-thumbnail-frame-candidates (:objects page))) + (assoc :page-id (:id page)))))) (defn get-file-data-for-thumbnail [{:keys [::db/conn] :as cfg} {:keys [data id] :as file} strip-frames-with-thumbnails] - (letfn [;; function responsible on finding the frame marked to be - ;; used as thumbnail; the returned frame always have - ;; the :page-id set to the page that it belongs. - (get-thumbnail-frame [{:keys [data]}] - (d/seek #(or (:use-for-thumbnail %) - (:use-for-thumbnail? %)) ; NOTE: backward comp (remove on v1.21) - (for [page (-> data :pages-index vals) - frame (-> page :objects ctt/get-frames)] - (assoc frame :page-id (:id page))))) - - ;; function responsible to filter objects data structure of + (letfn [;; function responsible to filter objects data structure of ;; all unneeded shapes if a concrete frame is provided. If no - ;; frame, the objects is returned untouched. + ;; frame, all objects are returned as a plain map. (filter-objects [objects frame-id] - (d/index-by :id (cfh/get-children-with-self objects frame-id))) + (if (some? frame-id) + (d/index-by :id (cfh/get-children-with-self objects frame-id)) + (into {} objects))) ;; function responsible of assoc available thumbnails ;; to frames and remove all children shapes from objects if @@ -152,29 +166,28 @@ objects)))] - (let [frame (get-thumbnail-frame file) - frame-id (:id frame) - page-id (or (:page-id frame) - (-> data :pages first)) + (binding [pmap/*load-fn* (partial fdata/load-pointer cfg id)] + (let [frame (get-thumbnail-frame data) + frame-id (:id frame) + page-id (or (:page-id frame) + (-> data :pages first)) - page (dm/get-in data [:pages-index page-id]) - page (cond-> page (pmap/pointer-map? page) deref) - frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page)))) + page (dm/get-in data [:pages-index page-id]) + page (cond-> page (pmap/pointer-map? page) deref) + frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page)))) - obj-ids (map #(thc/fmt-object-id (:id file) page-id % "frame") frame-ids) - thumbs (get-object-thumbnails conn id obj-ids)] + obj-ids (map #(thc/fmt-object-id (:id file) page-id % "frame") frame-ids) + thumbs (get-object-thumbnails conn id obj-ids)] - (cond-> page - ;; If we have frame, we need to specify it on the page level - ;; and remove the all other unrelated objects. - (some? frame-id) - (-> (assoc :thumbnail-frame-id frame-id) - (update :objects filter-objects frame-id)) + (cond-> (update page :objects filter-objects frame-id) + ;; If we have frame, we need to specify it on the page level + (some? frame-id) + (assoc :thumbnail-frame-id frame-id) - ;; Assoc the available thumbnails and prune not visible shapes - ;; for avoid transfer unnecessary data. - strip-frames-with-thumbnails - (update :objects assoc-thumbnails page-id thumbs))))) + ;; Assoc the available thumbnails and prune not visible shapes + ;; for avoid transfer unnecessary data. + strip-frames-with-thumbnails + (update :objects assoc-thumbnails page-id thumbs)))))) (def ^:private schema:get-file-data-for-thumbnail @@ -204,7 +217,6 @@ :file-id file-id) file (bfc/get-file cfg file-id :include-deleted? true - :realize? true :read-only? true) strip-frames-with-thumbnails (or (nil? strip-frames-with-thumbnails) ;; if not present, default to true diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 2ac2bf8424..e2f7f631a0 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -1373,6 +1373,73 @@ ;; (app.common.pprint/pprint rows) (t/is (= 1 (count rows))))))) +(t/deftest file-data-for-thumbnail-frame-on-other-page + (let [prof (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + page1-id (get-in file [:data :pages 0]) + page2-id (uuid/next) + rect-id (uuid/next) + frame-id (uuid/next) + + get-data (fn [] + (th/command! {::th/type :get-file-data-for-thumbnail + ::rpc/profile-id (:id prof) + :file-id (:id file) + :features cfeat/supported-features}))] + + (th/update-file* {:file-id (:id file) + :profile-id (:id prof) + :revn 0 + :vern 0 + :changes [{:type :add-obj + :page-id page1-id + :id rect-id + :parent-id uuid/zero + :frame-id uuid/zero + :obj (cts/setup-shape + {:id rect-id + :name "rect" + :type :rect})} + {:type :add-page + :id page2-id + :name "page 2"} + {:type :add-obj + :page-id page2-id + :id frame-id + :parent-id uuid/zero + :frame-id uuid/zero + :obj (cts/setup-shape + {:id frame-id + :name "frame" + :type :frame})}]}) + + (t/testing "no marked frame falls back to the first page" + (let [{:keys [error result]} (get-data)] + (t/is (nil? error)) + (t/is (= page1-id (get-in result [:page :id]))) + (t/is (nil? (get-in result [:page :thumbnail-frame-id]))) + (t/is (contains? (get-in result [:page :objects]) rect-id)))) + + (th/update-file* {:file-id (:id file) + :profile-id (:id prof) + :revn 1 + :vern 0 + :changes [{:type :mod-obj + :page-id page2-id + :id frame-id + :operations [{:type :set + :attr :use-for-thumbnail + :val true}]}]}) + + (t/testing "marked frame on another page is used" + (let [{:keys [error result]} (get-data)] + (t/is (nil? error)) + (t/is (= page2-id (get-in result [:page :id]))) + (t/is (= frame-id (get-in result [:page :thumbnail-frame-id]))) + (t/is (contains? (get-in result [:page :objects]) frame-id)))))) + (t/deftest file-thumbnail-ops (let [prof (th/create-profile* 1 {:is-active true}) file (th/create-file* 1 {:profile-id (:id prof) From 66c5e6fdfd438733589d53a418694eaea4d6da6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:02:23 +0000 Subject: [PATCH 026/325] :arrow_up: Bump the github-actions group across 1 directory with 8 updates Bumps the github-actions group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `1.12.0` | `3.2.0` | | [actions/github-script](https://github.com/actions/github-script) | `7.1.0` | `9.0.0` | | [actions/checkout](https://github.com/actions/checkout) | `6.1.0` | `7.0.1` | | [pnpm/setup](https://github.com/pnpm/setup) | `2.0.2` | `3.0.0` | | [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) | `4.3.1` | `6.3.0` | | [actions/cache/restore](https://github.com/actions/cache) | `5.1.0` | `6.1.0` | | [actions/cache/save](https://github.com/actions/cache) | `5.1.0` | `6.1.0` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` | Updates `actions/create-github-app-token` from 1.12.0 to 3.2.0 - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/create-github-app-token/compare/d72941d797fd3113feb6b93fd0dec494b13a2547...bcd2ba49218906704ab6c1aa796996da409d3eb1) Updates `actions/github-script` from 7.1.0 to 9.0.0 - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/f28e40c7f34bde8b3046d885e986cb6290c5673b...3a2844b7e9c422d3c10d287c895573f7108da1b3) Updates `actions/checkout` from 6.1.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/d23441a48e516b6c34aea4fa41551a30e30af803...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `pnpm/setup` from 2.0.2 to 3.0.0 - [Release notes](https://github.com/pnpm/setup/releases) - [Commits](https://github.com/pnpm/setup/compare/84cb39b217b10273981911c288cd62326dc7c6d2...fbda4c85fc2e1e08721cd8763afea8f48d60f024) Updates `aws-actions/configure-aws-credentials` from 4.3.1 to 6.3.0 - [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases) - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/7474bc4690e29a8392af63c5b98e7449536d5c3a...e1253824e5c10ff9df46874f81ed3ec929e19cfd) Updates `actions/cache/restore` from 5.1.0 to 6.1.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/caa296126883cff596d87d8935842f9db880ef25...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) Updates `actions/cache/save` from 5.1.0 to 6.1.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/caa296126883cff596d87d8935842f9db880ef25...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) Updates `actions/download-artifact` from 7.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/github-script dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: pnpm/setup dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/configure-aws-credentials dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/cache/restore dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/cache/save dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/auto-label.yml | 4 ++-- .github/workflows/build-bundle.yml | 4 ++-- .github/workflows/build-docker-devenv.yml | 2 +- .github/workflows/build-docker.yml | 4 ++-- .github/workflows/lint-workflows.yml | 2 +- .github/workflows/plugins-deploy-api-doc.yml | 4 ++-- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/plugins-deploy-packages.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/tests-backend.yml | 2 +- .github/workflows/tests-common.yml | 2 +- .github/workflows/tests-e2e.yml | 22 +++++++++---------- .github/workflows/tests-exporter.yml | 2 +- .github/workflows/tests-frontend.yml | 2 +- .github/workflows/tests-library.yml | 2 +- .github/workflows/tests-mcp.yml | 2 +- .github/workflows/tests-plugins.yml | 2 +- .github/workflows/tests-wasm.yml | 2 +- 19 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 0b6838f7c8..b64aa8e4ba 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate GitHub App token id: triage-app-token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.TRIAGE_APP_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} @@ -29,7 +29,7 @@ jobs: permission-organization-projects: write - name: Process Issue or PR - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.triage-app-token.outputs.token }} script: | diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 1172790408..88c04a2634 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -68,7 +68,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -136,7 +136,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 87856225bb..906adcd369 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -18,7 +18,7 @@ jobs: echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index a794565d25..a8d6a1976d 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -90,7 +90,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -188,7 +188,7 @@ jobs: echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }} diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index 23f9849899..eb3ca73ed3 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 4d0e5754d2..3bd9470178 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -39,7 +39,7 @@ jobs: echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -48,7 +48,7 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2.0.2 + uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 with: working-directory: plugins runtime: node@24.21.0 diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index 6165dd465b..c9ff731682 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -65,7 +65,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 11650fd5ef..40b71ede21 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -39,7 +39,7 @@ jobs: # [For new plugins] # Add more outputs here steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - id: filter diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 0f04b62615..0cba3f685d 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -37,7 +37,7 @@ jobs: echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -46,7 +46,7 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2.0.2 + uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 with: working-directory: plugins runtime: node@24.21.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e956f2310d..201150b826 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -55,7 +55,7 @@ jobs: # --- Publicly release the docker images --- - name: Configure ECR credentials - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: aws-access-key-id: ${{ secrets.DOCKER_USERNAME }} aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index 6a447d5737..a1df06c91a 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -63,7 +63,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml index a4d32c39b1..f8168dbade 100644 --- a/.github/workflows/tests-common.yml +++ b/.github/workflows/tests-common.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 0f6f0f8765..84db8d144d 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -110,7 +110,7 @@ jobs: # An empty `ref` makes checkout fall back to its default (the PR merge # ref on pull_request, the pushed ref on push). - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ inputs.gh_ref }} @@ -124,7 +124,7 @@ jobs: - name: Restore Bundle Cache id: restore - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.vars.outputs.bundle_key }} path: frontend/resources/public @@ -137,7 +137,7 @@ jobs: - name: Store Bundle Cache if: steps.restore.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.vars.outputs.bundle_key }} path: frontend/resources/public @@ -167,13 +167,13 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ inputs.gh_ref }} - name: Restore Cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ needs.build-bundle.outputs.bundle_key }} path: frontend/resources/public @@ -239,13 +239,13 @@ jobs: - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # The driver serves the prebuilt bundle from frontend/resources/public. - name: Restore Cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ needs.build-bundle.outputs.bundle_key }} path: frontend/resources/public @@ -279,13 +279,13 @@ jobs: - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Mocked mode serves the prebuilt bundle from frontend/resources/public. - name: Restore Cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ needs.build-bundle.outputs.bundle_key }} path: frontend/resources/public @@ -327,7 +327,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ inputs.gh_ref }} @@ -338,7 +338,7 @@ jobs: pnpm install --frozen-lockfile; - name: Download blob reports - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: frontend/all-blob-reports pattern: integration-blob-report-* diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index db32e3f59c..82b7b12f73 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index eff25d11df..9a6ff9ae4b 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml index 5a714705fb..76dbc0d079 100644 --- a/.github/workflows/tests-library.yml +++ b/.github/workflows/tests-library.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 50dc8a6806..8ccf4a083a 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml index 01ed31eccf..fff9c8002e 100644 --- a/.github/workflows/tests-plugins.yml +++ b/.github/workflows/tests-plugins.yml @@ -41,7 +41,7 @@ jobs: - /var/cache/github-runner/gitlib:/root/.gitlibs steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index 135d65a045..f3e1547321 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false From b2ea46a71aa63f93945c1ea1dc03804ceea278cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 5 Oct 2026 13:11:08 +0200 Subject: [PATCH 027/325] :wrench: Use client-id instead of app-id in auto-label workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/create-github-app-token v3 deprecates the app-id input in favour of client-id. Switch to the new TRIAGE_APP_CLIENT_ID secret. Signed-off-by: David Barragán Merino --- .github/workflows/auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index b64aa8e4ba..7467e61a79 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -21,7 +21,7 @@ jobs: id: triage-app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ secrets.TRIAGE_APP_ID }} + client-id: ${{ secrets.TRIAGE_APP_CLIENT_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} # No owner/repositories: the token is scoped to this repository. # Organization permissions (the project board) still apply. From 0eb1e8ba95f52492e46e499ee396575a588383f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 5 Oct 2026 14:32:04 +0200 Subject: [PATCH 028/325] :bug: Request pull-requests permission in auto-label token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .github/workflows/auto-label.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 7467e61a79..e3c435e40d 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -26,6 +26,7 @@ jobs: # No owner/repositories: the token is scoped to this repository. # Organization permissions (the project board) still apply. permission-issues: write + permission-pull-requests: write permission-organization-projects: write - name: Process Issue or PR From d3ca9a0b834de495e3491b3c0b2172b07d90fa6e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 6 Oct 2026 07:33:12 +0200 Subject: [PATCH 029/325] :sparkles: Add unified jobs substrate replacing legacy one-shot task system (#11542) * :tada: Move backend jobs to the unified job table substrate Replace the generic 'task' table with a unified 'job' table as the durable substrate for all backend jobs, and migrate every producer and consumer to it. The legacy 'task' table stops receiving writes but stays dormant (still cleaned by tasks-gc); upload-session chunking and the other post-branch upstream changes are preserved. New 0154 migration creates the 'job' table: dispatch and lifecycle columns (name, queue, priority, scheduled_at, retries, status, timestamps, props) plus optional user-facing ledger columns (profile_id, target, progress, error, result, resource_id, expires_at). No modified_at trigger: the application updates it on every write for lease and orphan detection. Partial indexes cover the dispatcher claim query, orphan scanning, and per-profile listing. New app.jobs public API: submit!/invoke!, heartbeats and progress reporting, a precompiled job-def registry (name, schema, handler, decoder, validator), an ephemeral request/response mode, and a management API for external workers. The dispatcher claims new/retry rows (FOR UPDATE SKIP LOCKED) with a plain JSON redis payload and marks lease-expired running rows as orphaned; the runner executes per-queue with the same retry conventions. New jobs GC (daily): deletes expired rows and retains internal terminal rows per :jobs-retention, touching referenced storage objects so storage-gc-touched reclaims them. Job resources live in a dedicated job-resource bucket with a reclaim branch in storage-gc-touched. All production call-sites (sendmail, delete-object, demo-purge, file-gc, offload, webhooks, quotes, nitrate bulk delete, snapshots, telemetry) and the ten cron tasks now submit through app.jobs; cron is a pure scheduler and the legacy worker registry, task-table inserts, and per-namespace handlers are removed. Includes migrations, dispatcher, runner, jobs, request/response, jobs GC, management API, and cron test suites. AI-assisted-by: glm-5.3-flash AI-assisted-by: muse-spark-1.3-contributor AI-assisted-by: kimi-k3 AI-assisted-by: mimo-v2.5 * :sparkles: Address review comments on jobs substrate Resolves the four open review threads on the jobs PR. Orphaned jobs are now marked aborted, a system-side terminal state with no retry: the dispatcher sweep stores the end event with outcome aborted in a single bulk insert in the same transaction, records the terminal outcome next to the orphaned counter, and reports an error log; orphans keep alerting through the log and the counter, with no user notification. The aborted state is terminal everywhere: the status CHECK, the sweep, the lifecycle writers, the jobs GC retention, the backlog gauges and the user status mapping, with tests covering each transition. The claim comment, the 2.20 doc version and the legacy task table notes are corrected, and objects-gc heartbeats per chunk. AI-assisted-by: muse-spark-1.3-contributor --- .agents/skills/review-plan/SKILL.md | 56 +- .github/workflows/tests-backend.yml | 4 + .serena/memories/backend/core.md | 39 +- .serena/memories/backend/storage.md | 12 +- .serena/memories/backend/subtleties.md | 23 +- .serena/memories/backend/testing.md | 3 + .serena/memories/clojure/idioms.md | 1 + .serena/memories/critical-info.md | 2 +- .serena/memories/devenv/core.md | 2 +- .serena/memories/prod-infra/core.md | 18 +- .serena/memories/scripts/ci.md | 6 + backend/src/app/config.clj | 53 +- backend/src/app/db.clj | 62 +- backend/src/app/email.clj | 103 +- backend/src/app/features/file_snapshots.clj | 14 +- backend/src/app/http/session.clj | 34 +- backend/src/app/jobs.clj | 1187 ++++++++++++++ backend/src/app/jobs/gc.clj | 164 ++ backend/src/app/jobs/metrics.clj | 248 +++ backend/src/app/loggers/audit.clj | 34 +- .../src/app/loggers/audit/archive_task.clj | 84 +- backend/src/app/loggers/audit/gc_task.clj | 27 +- backend/src/app/loggers/database.clj | 17 +- backend/src/app/loggers/mattermost.clj | 3 +- backend/src/app/loggers/webhooks.clj | 167 +- backend/src/app/main.clj | 376 +++-- backend/src/app/metrics.clj | 20 + backend/src/app/migrations.clj | 3 + .../app/migrations/sql/0154-add-job-table.sql | 150 ++ backend/src/app/nitrate.clj | 19 +- backend/src/app/redis.clj | 10 + backend/src/app/rpc.clj | 70 +- backend/src/app/rpc/commands/auth.clj | 44 +- backend/src/app/rpc/commands/comments.clj | 21 +- backend/src/app/rpc/commands/demo.clj | 10 +- backend/src/app/rpc/commands/feedback.clj | 32 +- backend/src/app/rpc/commands/files.clj | 28 +- .../src/app/rpc/commands/files_snapshot.clj | 4 +- backend/src/app/rpc/commands/profile.clj | 38 +- backend/src/app/rpc/commands/projects.clj | 20 +- backend/src/app/rpc/commands/teams.clj | 12 +- .../app/rpc/commands/teams_invitations.clj | 72 +- backend/src/app/rpc/management/jobs.clj | 140 ++ backend/src/app/rpc/management/nitrate.clj | 30 +- backend/src/app/rpc/nitrate/emails_helper.clj | 20 +- backend/src/app/rpc/quotes.clj | 150 +- backend/src/app/srepl/main.clj | 135 +- backend/src/app/storage.clj | 7 + backend/src/app/storage/gc_deleted.clj | 38 +- backend/src/app/storage/gc_touched.clj | 63 +- backend/src/app/storage/pending_gc.clj | 33 +- backend/src/app/storage/schema.clj | 6 + backend/src/app/tasks/delete_object.clj | 29 +- backend/src/app/tasks/demo_purge.clj | 54 +- backend/src/app/tasks/file_gc.clj | 72 +- backend/src/app/tasks/file_gc_scheduler.clj | 52 +- backend/src/app/tasks/objects_gc.clj | 120 +- backend/src/app/tasks/offload_file_data.clj | 35 +- backend/src/app/tasks/tasks_gc.clj | 39 - backend/src/app/tasks/telemetry.clj | 106 +- backend/src/app/worker.clj | 158 +- backend/src/app/worker/cron.clj | 155 +- backend/src/app/worker/dispatcher.clj | 319 ++-- backend/src/app/worker/runner.clj | 388 +++-- backend/test/backend_tests/binfile_test.clj | 11 + backend/test/backend_tests/config_test.clj | 21 + .../backend_tests/db_transaction_test.clj | 80 + backend/test/backend_tests/demo_test.clj | 46 +- .../test/backend_tests/email_sending_test.clj | 24 + .../backend_tests/file_gc_scheduler_test.clj | 28 + backend/test/backend_tests/helpers.clj | 63 +- .../backend_tests/http_middleware_test.clj | 18 +- backend/test/backend_tests/jobs_gc_test.clj | 361 +++++ .../test/backend_tests/jobs_metrics_test.clj | 307 ++++ .../test/backend_tests/jobs_request_test.clj | 308 ++++ backend/test/backend_tests/jobs_test.clj | 1434 +++++++++++++++++ .../loggers_audit_archive_task_test.clj | 8 +- .../backend_tests/loggers_webhooks_test.clj | 110 +- backend/test/backend_tests/metrics_test.clj | 18 + .../test/backend_tests/migrations_test.clj | 218 +++ backend/test/backend_tests/rpc_demo_test.clj | 10 +- .../test/backend_tests/rpc_feedback_test.clj | 25 + .../backend_tests/rpc_file_snapshot_test.clj | 2 +- backend/test/backend_tests/rpc_file_test.clj | 11 +- .../rpc_file_thumbnails_test.clj | 2 +- .../rpc_management_jobs_test.clj | 418 +++++ .../rpc_management_nitrate_test.clj | 34 +- .../test/backend_tests/rpc_nitrate_test.clj | 4 +- .../test/backend_tests/rpc_profile_test.clj | 40 +- backend/test/backend_tests/rpc_team_test.clj | 38 +- .../backend_tests/storage_metadata_test.clj | 3 +- backend/test/backend_tests/storage_test.clj | 98 +- .../test/backend_tests/worker_cron_test.clj | 201 +++ .../backend_tests/worker_dispatcher_test.clj | 329 ++++ .../test/backend_tests/worker_runner_test.clj | 566 +++++++ backend/test/backend_tests/worker_test.clj | 46 + scripts/ci | 6 + 97 files changed, 8796 insertions(+), 1533 deletions(-) create mode 100644 backend/src/app/jobs.clj create mode 100644 backend/src/app/jobs/gc.clj create mode 100644 backend/src/app/jobs/metrics.clj create mode 100644 backend/src/app/migrations/sql/0154-add-job-table.sql create mode 100644 backend/src/app/rpc/management/jobs.clj delete mode 100644 backend/src/app/tasks/tasks_gc.clj create mode 100644 backend/test/backend_tests/db_transaction_test.clj create mode 100644 backend/test/backend_tests/file_gc_scheduler_test.clj create mode 100644 backend/test/backend_tests/jobs_gc_test.clj create mode 100644 backend/test/backend_tests/jobs_metrics_test.clj create mode 100644 backend/test/backend_tests/jobs_request_test.clj create mode 100644 backend/test/backend_tests/jobs_test.clj create mode 100644 backend/test/backend_tests/migrations_test.clj create mode 100644 backend/test/backend_tests/rpc_management_jobs_test.clj create mode 100644 backend/test/backend_tests/worker_cron_test.clj create mode 100644 backend/test/backend_tests/worker_dispatcher_test.clj create mode 100644 backend/test/backend_tests/worker_runner_test.clj create mode 100644 backend/test/backend_tests/worker_test.clj diff --git a/.agents/skills/review-plan/SKILL.md b/.agents/skills/review-plan/SKILL.md index c690df6b85..431610db5e 100644 --- a/.agents/skills/review-plan/SKILL.md +++ b/.agents/skills/review-plan/SKILL.md @@ -6,64 +6,40 @@ slash: true # Review Plan -Act as a senior software engineer and perform a thorough review of an -implementation plan. +Act as a senior software engineer and perform a thorough review of an implementation plan. ## When to use -- The user asks to review a plan, in any phrasing: "review this plan", - "does this plan look right?", "second opinion on the plan" — or runs - `/review-plan`. -- A plan was just produced (typically by `/make-a-plan`) and the user - wants it evaluated before executing it. +- The user asks to review a plan, in any phrasing: "review this plan", "does this plan look right?", "second opinion on the plan" — or runs `/review-plan`. +- A plan was just produced (typically by `/make-a-plan`) and the user wants it evaluated before executing it. ## Instructions -1. **Determine the plan under review** from the session context (for example, a - plan just produced by `/make-a-plan`) or from a plan file path given by the - user (typically under `.agents/plans/`). If a file path is given, read the - file first so the complete plan is in context. -2. Delegate the review to the `general` subagent (via the task tool), unless the - user specifies another agent. Include in the prompt the - **`plan-review-criteria`** skill name and all user context. -3. When the subagent returns, output the review to the user verbatim. Do not - summarize it and do not act on its findings. -4. Right after the review, suggest the next step based on the verdict. These - are suggestions — the user decides, and any instruction overrides them: +1. **Determine the plan under review** from the session context (for example, a plan just produced by `/make-a-plan`) or from a plan file path given by the user (typically under `.agents/plans/`). If a file path is given, read the file first so the complete plan is in context. +2. Delegate the review to the `general` subagent (via the task tool), unless the user specifies another agent. Include in the prompt the **`plan-review-criteria`** skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not summarize it and do not act on its findings. +4. Right after the review, suggest the next step based on the verdict. These are suggestions — the user decides, and any instruction overrides them: - **Approve** → suggest `/implement-plan` to execute it. - - **Request changes** → suggest `/make-a-plan` to make a plan to address the - findings. + - **Request changes** → suggest `/make-a-plan` to make a plan to address the findings. ### Hard rule — read-only while reviewing -This flow is read-only **for the duration of the review**: from the moment it -starts until the user considers the review finished (including any feedback, -questions, or clarifications about it). During that period, never fix, -implement, edit files or create commits — not even "obvious" fixes derived from -the findings. Once the user explicitly states the review is done (or moves on to -a different task), this rule no longer applies and you act as a normal build -agent again. +This flow is read-only **for the duration of the review**: from the moment it starts until the user considers the review finished (including any feedback, questions, or clarifications about it). During that period, never fix, implement, edit files or create commits — not even "obvious" fixes derived from the findings. Once the user explicitly states the review is done (or moves on to a different task), this rule no longer applies and you act as a normal build agent again. ## Instructions for the subagent -1. Load the **`plan-review-criteria`** skill and follow its process and output - format. -2. Read `AGENTS.md` (if present) and follow its instructions for finding and - reading all related documentation and testing memories before reviewing. -3. Return in your final message the COMPLETE review, verbatim, exactly as the - skill instructs it to be produced. Do not summarize it — include the full - structured review. +1. Load the **`plan-review-criteria`** skill and follow its process and output format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and reading all related documentation and testing memories before reviewing. +3. Cada hallazgo debe tener un identificador único y estable, con el formato `F1`, `F2`, etc. El identificador debe aparecer en el título del hallazgo y no debe reutilizarse dentro de la misma revisión. Esto permite que el usuario pueda responder sobre un hallazgo concreto. +4. Return in your final message the COMPLETE review, verbatim, exactly as the skill instructs it to be produced. Do not summarize it — include the full structured review. ### Strong rules for the subagent 1. Do not invent problems. Every finding must be real and actionable. -2. Read-only: do not modify any file and do not create a commit — reviewing - never writes. -3. Be specific and constructive. "This could be better" is not helpful — explain - why and how. +2. Read-only: do not modify any file and do not create a commit — reviewing never writes. +3. Be specific and constructive. "This could be better" is not helpful — explain why and how. 4. Prioritize by impact. One structural issue outweighs ten nits. -5. Judge the plan as the implementer would: every task executable without - guessing, ordering follows the dependency graph, risks named. +5. Judge the plan as the implementer would: every task executable without guessing, ordering follows the dependency graph, risks named. ## User input, overrides and additional context diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index a1df06c91a..40b99ff3c5 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -80,6 +80,10 @@ jobs: - name: Tests working-directory: ./backend env: + # `clojure.core/assert` is elided at compile time, so without this + # flag the precondition asserts do not exist in the compiled code + # and the suite silently stops checking them. + PENPOT_FLAGS: "enable-backend-asserts" PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test" PENPOT_TEST_DATABASE_USERNAME: penpot_test PENPOT_TEST_DATABASE_PASSWORD: penpot_test diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index aa3d0975e5..287cd56dd2 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -37,21 +37,46 @@ Backend RPC command areas without focused memories include access tokens, binfil `app.db` helpers accept cfg, pool, or conn in most places and convert kebab-case to snake_case: - `db/get`, `db/get*`, `db/query`, `db/insert!`, `db/update!`, `db/delete!`. +- `db/get*`/`db/query` signature is `(ds table where-params & {:as opts})`: trailing keywords become an opts MAP (ignored by the select builder — use `{::db/remove-deleted false}` map as the single vararg to also see rows with `deleted_at` set), while a trailing ODD number of keywords crashes with "Don't know how to create ISeq from: Keyword". Extra keywords are NOT a column filter: rows always come back with all columns. +- Next.js/JDBC caveat: where-maps don't support value vectors (no `IN`); use `status = ANY(?)` with `db/create-array` inside `db/tx-run!`. - Use `db/run!` for multiple operations on one connection. - Use `db/tx-run!` for transactions. +- `job`-substrate specifics (unified jobs): the only writers of a job status are `claim`, `retry-job`, `complete`, `fail` and `cancel` in `app.jobs`; each one is a conditional update plus its `job_event` row in the same transaction, so history can never contradict the row. A writer that affects no row writes no event and no metric. They never join the caller's transaction, because a lifecycle write reports on work that already happened: a rolled back caller cannot undo a transition, and an independent connection cannot see the caller's uncommitted job row, so a job must be committed before it can be claimed. GC deletes return resource ids via `RETURNING` inside one transaction. +- Every public function of `app.jobs` takes a cfg map. A bare pool or connection is a caller bug and is not coerced: the substrate has no conversion of its own, and the ones in `app.db` are the db layer's business. +- Every new foreign key of the substrate is `DEFERRABLE`, `job_event.job_id` included. +- Some tables carry a SQL deletion guard (`raise_deletion_protection` + a `deletion_protection__tgr` statement trigger): `profile`, `team`, `team_font_variant`, `file_media_object`, `file_thumbnail`, `file_tagged_object_thumbnail` and `job`. A `DELETE` raises unless the transaction runs `SET LOCAL rules.deletion_protection TO off` (the guard is on when the GUC is unset, empty or `on`); see `app.tasks.objects-gc`, `app.jobs.gc` and `th/db-force-delete`. Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table. -For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump -the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`). +For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`). For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/subtleties`. -## Background tasks +## Background tasks (unified jobs) -A task handler is an Integrant component with `ig/assert-key`, `ig/expand-key`, and `ig/init-key`, returning the function run by the worker. New tasks also need wiring in `app.main`: handler config, worker registry entry, and cron entry if scheduled. +Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init). The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref). + +- `::jobs/handler` is `(fn [context params] ...)`. It is NOT given the runner cfg: a job-def closes over its own dependencies, and handing it the runner cfg would let a handler reach the pool or any other component. A wrapper passes the context to its implementation only when the implementation takes it; most do not, and their implementation stays `[cfg params]`. +- The context is built by `jobs/make-context` and has exactly four keys: `id`, `name`, `label`, `resource-id`, behind a closed schema. `queue` is absent on purpose (a routing detail of the dispatcher, not a property of the job) and so are `retry-num`/`max-retries`/`attempt` (retry policy belongs to the runner, and an attempt number cannot be durable because the `noop` strategy re-runs without incrementing the counter). +- `jobs/invoke` is the in-process escape hatch: it delivers a nil context without a row, validates and delivers `::jobs/context` when given, and keeps `::jobs/job-id` independent. `::jobs/context` and `::jobs/job-id` are cfg options, not params. +- Business params live in the `params` column (plain JSON, decoded with the job-def decoder). `submit` takes an optional `::jobs/profile-id` and `::jobs/resource-id`, both validated as UUIDs and stored as columns; nothing is inferred from `params`. No caller passes a profile-id yet. +- `heartbeat` uses named options `[cfg & {:keys [job-id progress] :as options}]` and returns the number of durable writes. The job id falls back to `::jobs/job-id` on the cfg and then to the runner-bound `*job-id*`. A progress report needs `current`, allows optional `total` and `stage` (max 250 chars) and takes no other key. +- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. + +For worker dispatch, cron, retry semantics (`ex/raise :type ::wrk/retry` with `:delay`/`:strategy`), deduplication, job events, and queue internals: `mem:backend/subtleties`. + +## Jobs metrics + +- `app.jobs.metrics` owns the jobs metric names, bounded labels and the periodic backlog sampler. +- Metrics use only `name`, `queue`, `outcome`, `reason`, `stage` and `kind` labels. Job IDs, profile IDs, params, reply bodies and exception text must never be labels. Every label goes as-is (lowercased where case varies): values come from fixed call sites, so a new value in Prometheus means new code, not noise. Only a missing value falls back (`other`, or `failed` for outcomes). +- The canonical job name is a keyword (`app.jobs/schema:job-name`): `submit` and `invoke` reject strings. The row stores text, so `jobs/get-job-def` still coerces (it is the runner's boundary), cron entries carry the job under `:job` with `d/name` only at the SQL/log boundary, and test helpers keyword-ize row names before invoking. +- The durable lifecycle metrics are submitted, dispatched, completed, retries, orphaned and rescheduled counters; queue-wait, execution and total-time histograms; dispatcher and backlog state; GC, cron and ephemeral request metrics. +- `app.metrics/run!` is safe for recording failures, but metrics components still require a valid metrics instance. Durable submit call sites carry `::mtx/metrics` through RPC or job-def configuration. The jobs substrate takes the **cfg**, not the instance, and resolves it with `app.metrics/instance`, which raises `:missing-metrics` when absent: `app.jobs`, `app.jobs.metrics`, the runner, the dispatcher, cron and the jobs GC all follow that. `app.http`, `app.rpc.climit` and `app.storage.s3.metrics` still take the instance and were left out of scope on purpose, so do not read the codebase as uniform. The six job writers resolve at the top of their body, before writing, because their metric lands in a `db/after-commit` callback that swallows exceptions. The GC, the dispatcher and cron resolve inside that callback instead, so their guarantee rests on the component schema instead. A job that calls `heartbeat` from a component cfg must carry `::mtx/metrics` on it, and the event helper records the counter from the cfg the transaction hands it. +- SQL-backed job metrics are registered with `app.db/after-commit` from INSIDE the transaction that changed the row, so a rollback cannot inflate submitted, terminal, retry, event or dispatcher counters. Every lifecycle writer owns that transaction (`app.jobs/own-transaction` drops `::db/conn`), and `db/transact!` gives a transaction that joins no other its own callback context, so the write and its metric commit or vanish together. `submit` is the exception and joins the caller on purpose: creating a job is part of the caller's business work. +- `penpot_jobs_events_total` counts stored job events with no labels: the interesting split is `kind`, and grouping a growing append-only table to build a metric is not worth the query on the write path. +- The backlog sampler is `::jobs-metrics/sampler`; it runs every 30 seconds on worker-enabled, writable systems, groups by status and publishes the oldest pending age, and does not start on a read-only database. +- `penpot_tasks_timing` remains exported for compatibility while the jobs-specific histograms are adopted. -For worker dispatch, cron, retry semantics, deduplication, and queue internals: `mem:backend/subtleties`. ## REPL @@ -98,9 +123,7 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Linting:** `pnpm run lint:clj`. * **Formatting:** `pnpm run check-fmt:clj` to check, `pnpm run fmt:clj` to fix. After running `fmt:clj`, `check-fmt:clj` is redundant. Avoid unrelated whitespace diffs. -**Before linting:** if delimiter errors are suspected (after LLM edits), run -`scripts/paren-repair` on the affected files first. Delimiter errors produce -misleading linter/compiler output. See `mem:scripts/paren-repair`. +**Before linting:** if delimiter errors are suspected (after LLM edits), run `scripts/paren-repair` on the affected files first. Delimiter errors produce misleading linter/compiler output. See `mem:scripts/paren-repair`. ## Testing diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index 4a48cd8c71..baeba165a2 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -30,6 +30,13 @@ - `objects-gc` removes deleted domain rows and touches their storage object IDs. - Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. +## GC Cost Model + +- `sto/touch-object!` is one `UPDATE storage_object SET touched_at`: DB-only, never touches S3/FS backend content. +- Physical FK cascades are avoided by convention; domain deletion is logical (`deleted_at`) everywhere. GC removes small objects first, then parents, then grandparents (see `deletion-proc-vars` order in `app.tasks.objects-gc`). Per-row delete cost is small and bounded. +- `objects-gc` sweeps in chunks of 100 (`::chunk-size`, overridable per job params); `storage-gc-deleted` in chunks of 25. Each chunk is one transaction: cursor fetch + single-row deletes/updates + commit. +- Consequence: per-chunk cost is DB-only and bounded, so these sweeps cannot outrun the 30-minute jobs lease in practice (would need millions of pending rows in one proc). Per-chunk `heartbeat` where present is belt-and-braces, not load-bearing. + ## Connection Reuse Details ### `app.storage/resolve` patterns: @@ -87,12 +94,13 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` | `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. | | `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. | | `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. | +| `job-resource` | Storage objects owned by job rows (`job.resource_id`). | Yes | Authentication required | jobs-GC touch → reference scan. | -- The valid bucket set lives in `app.storage/valid-buckets`. +- The valid bucket set is `app.storage.schema/metadata-buckets` (derived from `bucket-requirements`, where a new bucket and its required metadata keys are one entry); `app.storage/valid-buckets` aliases it. - `file-media-object` is the default bucket for old rows without bucket metadata. - Do not assign a new bucket without adding its access and cleanup behavior. - The touched-object collector raises an internal error for an unknown bucket. -- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, `upload-session`, and `organization`. +- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, `upload-session`, `job-resource`, and `organization`. - It does not support `file-data-fragment` or `file-change`. ## Access Rules diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index ad6c4321ae..41f781070b 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -14,17 +14,24 @@ - Most `app.db` helpers accept a pool, connection, or map containing `::db/pool` / `::db/conn`; preserve that convention in shared code. - `db/tx-run!` uses `next.jdbc.transaction/*nested-tx* :ignore`: nested transaction calls reuse the outer transaction, not a savepoint. Use explicit savepoints when nested rollback semantics matter. +- Whether a call is nested is decided by the CONNECTION, not by an ambient flag: `db/transact!` binds `*after-commit-conn*` around the body, and a transactable that is not identical to that connection is an independent unit of work that owns its own after-commit context. A `tx-run!` that passes a pool while a transaction is open therefore commits on its own and drains its own callbacks, instead of leaving them in a context that may roll back. +- `after-commit` callbacks run on commit only. A transaction marked `::db/rollback` (used by `srepl` and `app.tasks/offload-file-data`) commits nothing, so it runs none of them. - `db/run!` opens/reuses one connection but does not create a transaction. -- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Worker task props use Transit and are decoded with `decode-transit-pgobject`. +- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`). - Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash. ## Workers and cron -- Task queues are tenant-prefixed. Submit dedupe only removes not-yet-due `new` tasks with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. -- The dispatcher selects `new`/`retry` tasks with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes Redis payload `[id scheduled-at]`. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. -- Lost `scheduled` tasks are rescheduled after about 5 minutes; `running` tasks older than about 24 hours are marked failed as orphans. -- A task handler that is missing or returns an invalid result currently defaults to completed after warning. Throwing with `ex-data :type ::retry` controls retry behavior; `:strategy ::noop` retries without incrementing retry count. -- Cron jobs lock their `scheduled_task` row with `FOR UPDATE SKIP LOCKED`, disable statement/idle-in-transaction timeouts locally, and reschedule themselves in `finally` unless interrupted. Worker, dispatcher, and cron components do not start when the DB pool is read-only. +- `job` carries `tenant` (the instance the job belongs to) and a bare `queue`. Several instances share one database and the tenant is what keeps an instance from claiming another environment's jobs, so every query that acts on one instance's work filters on `tenant`: dispatch claim, orphan sweep, lost-job reschedule, cron no-overlap count, submit dedupe. The `job__dispatcher__idx` leads with `tenant` (equality) then `(priority DESC, scheduled_at)`; `queue` does not lead because the dispatcher claims every queue of its tenant. The `:` prefix survives only in the Redis key, composed once by `app.worker/queue-key`. +- Submit dedupe only removes not-yet-due `new` jobs of the same tenant with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. +- The dispatcher selects `new`/`retry` jobs with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes the JSON payload `[id scheduled-at]` to the `penpot.worker.queue::` Redis list. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. +- Lost `scheduled` jobs are rescheduled after 5 minutes; `running` jobs untouched longer than `:jobs-lease` (default 30 min) are marked `aborted` with `app.jobs/orphan-error` (`type :internal`, `code :orphan`). `aborted` is a system-side terminal state like `cancelled` (never retried, swept by retention like other terminals, reported with an error log); long jobs must `heartbeat` per chunk so a healthy backlog is never mistaken for a dead process. The `:progress` report is optional and only feeds the durable log. +- An orphan gets its `end` event (`outcome "aborted"`) from the sweep itself: one bulk `INSERT ... SELECT` in the same transaction as the bulk `UPDATE`, never one insert per orphan. No msgbus notification goes out for it; orphans alert through the error log and the orphaned counter. +- `job_event` is append-only and deleted by cascade with the job. `progress` is a row there, not a column: no Redis and no mutable column to keep in sync. +- `job` is deletion-protected at the SQL level (`deletion_protection__tgr`): a stray `DELETE FROM job` raises instead of dropping the row and its `job_event` cascade without touching the referenced `storage_object`. The only intended deleters, the jobs GC and the submit dedupe, disable the guard with `SET LOCAL rules.deletion_protection TO off` inside their transaction; tests use `th/db-force-delete`. +- A missing job-def raises (`:no-job-definition`) instead of completing. Throwing with `ex-data :type ::retry` still controls retry behavior; `:strategy ::noop` retries without incrementing retry count. +- Cron entries claim their `scheduled_task` row with `FOR UPDATE SKIP LOCKED`, disable statement/idle-in-transaction timeouts locally, submit one `job` row per entry when no active instance exists (no-overlap), and reschedule themselves in `finally` unless interrupted. Worker, dispatcher, and cron components do not start when the DB pool is read-only. +- Integrant params schemas name injected components as bare `::ns/key` entries (pool, metrics, msgbus, job defs), and a bare entry is a malli registry lookup, not `any?`: the owning namespace must `sm/register!` it at load, or the backend crashes at boot with `:malli.core/invalid-schema` instead of asserting. Unit tests never build those keys, so `backend-tests.worker-test` compiles the three worker schemas to catch it. ## Config and HTTP/session middleware @@ -47,7 +54,9 @@ ## Metrics recording -- `app.metrics/run!` is safe by default: a recording failure never throws (a metrics bug must not change the behavior of the measured operation). The first failure per metric id logs at `warn`, later ones at `debug`. The `instance` precondition is a plain assert (the backend enables `:backend-asserts`), and the collector lookup sits outside the recording guard, so a missing instance fails hard even when asserts are disabled. `::mtx/metrics` is required by the storage, s3-backend, and db-pool schemas; `app.db` wires the prometheus `MetricsTrackerFactory` unconditionally. Storage-specific metric contracts: `mem:backend/storage`. +- `app.metrics/run!` is safe by default: a recording failure never throws (a metrics bug must not change the behavior of the measured operation). The first failure per metric id logs at `warn`, later ones at `debug`. The `instance` precondition is a plain assert (the backend enables `:backend-asserts`), and the collector lookup sits outside the recording guard, so a missing instance fails hard even when asserts are disabled. `::mtx/metrics` is required by the storage, s3-backend, db-pool and jobs APIs; jobs helpers never silently skip a missing metrics instance. The resolver is `app.metrics/instance` and it raises, unlike `run!`. +- `app.msgbus` (`pub!`, `sub!`, `purge!`) keeps taking the **instance** on purpose: msgbus is what produces it, and `app.rpc.notifications` already shows the caller passing a cfg. `app.jobs` resolves `::mbus/msgbus` from the cfg internally, and `insert-event` keeps its explicit pre-insert check so a profile job never loses its notification. +- `app.db/after-commit` is the boundary for SQL-backed metrics: callbacks are drained by the commit of the transaction that owns the connection they were registered on, and discarded when it rolls back. Because nesting follows the connection, that transaction is the outermost one only for a real nested call. ## Storage and media diff --git a/.serena/memories/backend/testing.md b/.serena/memories/backend/testing.md index 23ae25c31c..cacfbadfca 100644 --- a/.serena/memories/backend/testing.md +++ b/.serena/memories/backend/testing.md @@ -5,8 +5,11 @@ JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`. - READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs. - All CLI commands must be executed from the `backend/` subdirectory. - Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed. +- **Preferred way to run them: `scripts/ci --test`**, which runs lint, tests and format per module and writes a log per task under `.ci-logs/`. It exports `PENPOT_FLAGS` with `enable-backend-asserts` appended, so use it rather than calling `clojure -M:dev:test` by hand. +- **Why the flag matters:** `clojure.core/assert` is elided at compile time when `*assert*` is false, and `app.config` only sets that var from the flags when it loads. The test fixture redefines the flags after that, so they never reach it: without the flag the suite runs with every assertion compiled out, and a test that depends on an assert only means something with it set. GitHub Actions sets it too. When running the suite directly, the command is `PENPOT_FLAGS="enable-backend-asserts" clojure -M:dev:test`. - Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. - Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var. +- Multiple focus symbols are supported by REPEATING the flag (kaocha accumulates them, last-wins does not apply): `clojure -M:dev:test --focus backend-tests.ns-one --focus backend-tests.ns-two` — preferred over a shell loop of isolated runs. - Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas. - If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. - RPC test helpers `command!`/`management-command!` split the data map: qualified keys become server params, unqualified keys become request body params. To inject request-level context (headers, `:app.http/auth-key-id`, ip), pass a map under `:app.http/request` metadata; non-map `IRequest` stubs fall back to a dummy request. \ No newline at end of file diff --git a/.serena/memories/clojure/idioms.md b/.serena/memories/clojure/idioms.md index a4e9f3af72..d7f40ce740 100644 --- a/.serena/memories/clojure/idioms.md +++ b/.serena/memories/clojure/idioms.md @@ -9,3 +9,4 @@ Behaviors confirmed against the language/stdlib — do not re-derive from assump - Valid: `(defn ^:async f)`, `(defn- ^:async f)`, `(^:async fn [] …)` (the latter is what stock `t/async` generates itself). - `^:async` fns never throw synchronously (rejected promises instead); `try/catch/finally` supported; continuations are microtasks, timers and RxJS schedulers are macrotasks (FIFO). - `!` suffix allowed cases (only exceptions to the no-new-`!` rule in `mem:clojure/design-rules`): mutating public `app.db` API (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, `exec!`, ...) and fns whose contract is to work over transients (stdlib `conj!`, `assoc!`, `dissoc!`, `disj!`, `persistent!` and custom helpers over them); any further case must be registered here with justification before use. +- Penpot extends `clojure.core/Inst` in `app.common.time` ( JVM-only section), so `inst-ms` also accepts `Duration` (returns `.toMillis`, NOT epoch millis), `FileTime` and `OffsetDateTime`. It only works where that namespace is loaded; on a bare `Duration`, prefer explicit `.toMillis`, which reads as what it is (an amount, not an instant). diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index f0cec128dc..c49915d956 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -57,7 +57,7 @@ The memory is structured in a way that you can get the critical information abou - `scripts/gh.py` — GitHub operations helper: list milestone issues, fetch PR details, compare against CHANGES.md. Requires `gh` CLI. See `mem:scripts/gh`. - `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`. - `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm workspaces (root, modules, member packages). Keeps the shared pnpm store at `/.pnpm-store` unless `--store`; ignores `external/` and `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`. -- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. See `mem:scripts/ci`. +- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`. # Dependency graph diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 7bd436221a..d530399fa1 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -27,7 +27,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par ## Worker policy -Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`. +Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. Workers are pure fire-and-forget: `jobs/submit` inserts a row into the shared Postgres `job` table and returns (the legacy `task` table stays dormant); RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `jobs/submit` `dedupe` is racy across submitters); details in `mem:prod-infra/core`. Each workspace is independent and can be started/stopped in any order. Shared infra (Postgres, RustFS, etc.) is shut down only when no instances remain running. diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index 8d6312562e..d4999d86b8 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -4,29 +4,33 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose ## Services -- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends. +- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `job` work queue (plus the dormant legacy `task` table), `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends. - **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue::`. `PENPOT_REDIS_URI`. - **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses RustFS. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`. -- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task). +- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker job). - **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`. ## Task queue and worker model -Async tasks are enqueued via `wrk/submit!` (`app.worker`), which inserts a row into the shared Postgres `task` table tagged with `queue = ":"`. Submission is **fire-and-forget** — RPC handlers never poll, never wait, and workers never publish to msgbus. The only completion signal is the `task` row's `status` / `completed_at` columns, which nothing in `rpc/` reads. Soft-delete RPCs return immediately after marking the top-level row, leaving the cascade and reaping to workers. +Async jobs are enqueued via `jobs/submit` (`app.jobs`), which inserts a row into the shared Postgres `job` table tagged with `queue = ":"`. Submission is **fire-and-forget** — RPC handlers never poll and never wait. The completion signal is the `job` row's `status` / `completed_at` columns, which nothing in `rpc/` reads today. A job submitted with `::jobs/profile-id` also publishes a `:job-event` message on that profile's msgbus topic after each transaction commits, but no caller passes a profile-id yet, so nothing reaches msgbus in production. Soft-delete RPCs return immediately after marking the top-level row, leaving the cascade and reaping to workers. -Workers run on backends with `enable-backend-worker` in `PENPOT_FLAGS`. Each worker-enabled backend has a `dispatcher` (polls `task` with `FOR UPDATE SKIP LOCKED`, marks status='scheduled', RPUSHes claimed task IDs into **its own** Redis list) and one or more `runner`s per queue (BLPOP from that same local list, execute, update the Postgres row). The Redis hand-off list is purely intra-backend — cross-backend coordination happens at the Postgres row level. +Workers run on backends with `enable-backend-worker` in `PENPOT_FLAGS`. Each worker-enabled backend has a `dispatcher` (polls `job` with `FOR UPDATE SKIP LOCKED`, marks status='scheduled', RPUSHes the JSON `[id scheduled-at]` payloads into **its own** Redis list) and one or more `runner`s per queue (BLPOP from that same local list, execute, update the Postgres row). The Redis hand-off list is purely intra-backend — cross-backend coordination happens at the Postgres row level. ## Cross-backend safety -Postgres row locking is the only correctness primitive: `task` claims via `FOR UPDATE SKIP LOCKED`, cron firing via `FOR UPDATE SKIP LOCKED` on the `scheduled_task` row, plus task-handler-internal locks (e.g. `file_gc_scheduler` locks candidate file rows). This makes the work-claim path safe across any number of worker-enabled backends. +Postgres row locking is the only correctness primitive: `job` claims via `FOR UPDATE SKIP LOCKED`, cron firing via `FOR UPDATE SKIP LOCKED` on the `scheduled_task` row, plus job-handler-internal locks (e.g. `file_gc_scheduler` locks candidate file rows). This makes the work-claim path safe across any number of worker-enabled backends. Two known race patterns survive multi-backend operation: -- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `tasks-gc`, `upload-session-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-archive` (depends on archive target idempotency). -- **`wrk/submit! ::dedupe true`** does a non-atomic `DELETE` then `INSERT`. Concurrent cross-backend submits can both bypass the `DELETE` (each sees the other's uncommitted insert as absent) and end up with duplicate `'new'` rows. Each row claims and runs once independently, so the underlying work is fine; the "at most one pending" guarantee weakens. +- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `storage-pending-gc`, `jobs-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-archive` (depends on archive target idempotency). +- **`jobs/submit ::dedupe true`** does a non-atomic `DELETE` then `INSERT`. Concurrent cross-backend submits can both bypass the `DELETE` (each sees the other's uncommitted insert as absent) and end up with duplicate `'new'` rows. Each row claims and runs once independently, so the underlying work is fine; the "at most one pending" guarantee weakens. Penpot in production lives with both: horizontal-scale deployments accept "exactly-once" as "essentially-once for idempotent operations." Devenv parallel instances handle it by running workers only on ws0 (see `mem:devenv/core`). +## Jobs observability + +Worker-enabled, writable backends publish unified jobs metrics through the normal Prometheus registry. Counters describe submit, dispatch, terminal outcomes, retries, orphan recovery, rescheduling and total stored job events (unlabelled); histograms separate queue wait, handler execution and total job age. SQL-backed events are recorded after the outer PostgreSQL transaction commits. The `::jobs-metrics/sampler` component publishes backlog by status and the oldest pending-job age every 30 seconds. All labels are bounded operational values; job IDs, profile IDs, params and error text are excluded. Read-only backends do not start the sampler or the workers. + ## See also - Devenv composition and the ws0-only worker placement: `mem:devenv/core`. diff --git a/.serena/memories/scripts/ci.md b/.serena/memories/scripts/ci.md index 69d3fa6968..696ba817aa 100644 --- a/.serena/memories/scripts/ci.md +++ b/.serena/memories/scripts/ci.md @@ -4,6 +4,12 @@ monorepo modules and prints a per-task summary. It is the local equivalent of CI; use it to verify changes before declaring work done. +It also exports `PENPOT_FLAGS` with `enable-backend-asserts` appended (keeping +whatever the caller already set), which is what puts `clojure.core/assert` +into the compiled test code. Prefer it over calling `clojure -M:dev:test` by +hand for that reason alone: without the flag the suite passes while every +assertion is compiled out. GitHub Actions sets the same flag. + ## When to use - After implementing or fixing code in a module: run its checks before diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index c4f097c373..10a97b7bee 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -77,6 +77,10 @@ :telemetry-uri "https://telemetry.penpot.app/" + :jobs-lease (ct/duration {:minutes 30}) + :jobs-retention (ct/duration {:days 7}) + :jobs-request-timeout (ct/duration {:minutes 2}) + :media-max-file-size (* 1024 1024 30) ; 30MiB :font-max-file-size (* 1024 1024 30) ; 30MiB @@ -103,6 +107,23 @@ :ssrf-allowed-hosts #{} :ssrf-extra-blocked-cidrs #{}}) +(def schema:tenant + "Tenant identifier: hostname-label-style (letters, digits and + hyphens). It is interpolated into Redis keys separated by dots (msgbus + topics, rate-limit buckets, exporter job keys, which are also matched + with a glob pattern), so `.` and whitespace are rejected at startup + because one tenant would then be able to read or overwrite another's + keys. + + `%`, `_` and `:` are rejected too, and the reason they were added is + gone: the job queue used to be filtered with a LIKE over a + `:` prefix and now has a `tenant` column. They are kept + because narrowing the rule is a startup contract change with no real + tenant asking for it, and a tenant that has one would have to be + renamed. Tenants using those characters must be renamed before + upgrading." + [:re #"^[A-Za-z0-9-]+$"]) + (def schema:config (do #_sm/optional-keys [:map {:title "config"} @@ -110,7 +131,7 @@ [:admins {:optional true} [::sm/set ::sm/email]] [:secret-key {:optional true} :string] - [:tenant {:optional false} :string] + [:tenant {:optional false} schema:tenant] [:is-saas ::sm/boolean] [:public-uri {:optional false} ::sm/uri] [:host {:optional false} :string] @@ -173,6 +194,9 @@ [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] + [:jobs-lease {:optional true} ::ct/duration] + [:jobs-retention {:optional true} ::ct/duration] + [:jobs-request-timeout {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] [:default-blob-version {:optional true} ::sm/int] [:allow-demo-users {:optional true} ::sm/boolean] @@ -190,6 +214,7 @@ [:scheduled-executor-parallelism {:optional true} ::sm/int] ;; REVIEW [:worker-default-parallelism {:optional true} ::sm/int] [:worker-webhook-parallelism {:optional true} ::sm/int] + [:worker-cron-parallelism {:optional true} ::sm/int] [:database-password {:optional true} [:maybe :string]] [:database-uri {:optional true} ::sm/uri] @@ -423,6 +448,32 @@ [& segments] (apply join-uri (c/get config :public-uri) segments)) +(defn get-jobs-lease + "Max time a job can run without touching modified_at (heartbeat or + progress) before the dispatcher marks it as `aborted` (system-side + terminal, never retried, reported with an error log)." + [] + (or (c/get config :jobs-lease) + (ct/duration {:minutes 30}))) + +(defn get-jobs-request-timeout + "Default timeout for the ephemeral request! calls (waiting for the + reply-key blpop); can be overridden per call. Any override is applied + by raising the pooled connection command timeout for the duration of + the call, which the pool restores on return." + [] + (or (c/get config :jobs-request-timeout) + (ct/duration {:minutes 2}))) + +(defn get-jobs-retention + "How long terminal (completed/failed/cancelled/aborted) internal job rows are + kept before the jobs GC deletes them. The legacy `task` table is not + touched by the jobs GC: while both versions run in parallel it is + cleaned by the legacy `tasks-gc` of that version." + [] + (or (c/get config :jobs-retention) + (ct/duration {:days 7}))) + (defn get "A configuration getter. Helps code be more testable." ([key] diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index d34911ff32..c9998b49b8 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -583,12 +583,70 @@ (l/trc :hint "explicit rollback requested (savepoint)") (.rollback conn sp)))) +(def ^:dynamic *after-commit-context* nil) + +;; the connection the ambient transaction owns, bound by `transact!` for +;; the extent of its body. It is what tells a nested call (same +;; connection) from an independent one (any other). +(def ^:dynamic *after-commit-conn* nil) + +(defn- run-after-commit-callbacks + [callbacks] + (doseq [callback callbacks] + (try + (callback) + (catch Throwable cause + (l/wrn :hint "after-commit callback failed" :cause cause))))) + +(defn after-commit + "Run a callback after the current transaction commits. + + The callback runs immediately when called outside a transaction. + + Nesting follows the connection, not an ambient flag. A transaction + opened on the connection the surrounding one owns is part of it and + shares its context, so callbacks are never drained before the + transaction that owns the connection commits. A transaction opened on + any other connection is an independent unit of work: it owns its + context, drains its callbacks at its own commit and drops them if it + rolls back." + [f] + (if *after-commit-context* + (swap! *after-commit-context* conj f) + (f))) + +(defn- joins-transaction? + "True when `transactable` is the very connection the ambient + transaction owns, which is what makes this call part of it." + [transactable] + (boolean + (and *after-commit-conn* + (identical? transactable *after-commit-conn*)))) + (defn transact! "A lower-level function for executing function in a transaction" ([transactable f] (transact! transactable f {})) ([transactable f opts] - (binding [next.jdbc.transaction/*nested-tx* :ignore] - (jdbc/transact transactable f opts)))) + (if (joins-transaction? transactable) + (binding [next.jdbc.transaction/*nested-tx* :ignore] + (jdbc/transact transactable f opts)) + (let [context (atom [])] + (try + (let [result (binding [*after-commit-context* context] + (binding [next.jdbc.transaction/*nested-tx* :ignore] + (jdbc/transact + transactable + (fn [conn] + (binding [*after-commit-conn* conn] + (f conn))) + opts)))] + ;; a rollback-only transaction commits nothing, so its + ;; callbacks have nothing to follow + (when-not (:rollback-only opts) + (run-after-commit-callbacks @context)) + result) + (finally + (reset! context []))))))) (defn tx-run! "Run a function in a transaction." diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index 8efa36ffbb..bae1d22075 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -6,6 +6,7 @@ (ns app.email "Main api for send emails." + (:refer-clojure :exclude [send]) (:require [app.common.data :as d] [app.common.data.macros :as dm] @@ -16,8 +17,8 @@ [app.config :as cf] [app.db :as db] [app.db.sql :as sql] + [app.jobs :as jobs] [app.util.template :as tmpl] - [app.worker :as wrk] [clojure.java.io :as io] [cuerdas.core :as str] [integrant.core :as ig]) @@ -296,23 +297,32 @@ [email-factory params] (email-factory params)) -(defn send! - "Schedule an already defined email to be sent using asynchronously - using worker task." - [{:keys [::conn ::factory] :as params}] - (assert (db/connectable? conn) "expected a valid database connection or pool") - +(defn send + "Schedule an already defined email to be sent asynchronously + using the unified jobs machinery. The first `cfg` parameter is the + connectable context that provides the `::jobs/defs` registry (an RPC + method cfg or the system) and can provide a default connection; the + second `params` provides the email data and optionally `::factory` + and `::reuse-conn`. When `::reuse-conn` is true the submission + reuses the caller's existing `::db/conn` (from cfg) instead of + acquiring a fresh one from the pool." + [cfg {:keys [::reuse-conn ::factory] :as params}] + (when (and reuse-conn (nil? (::db/conn cfg))) + (ex/raise :type :validation + :code :missing-connection + :hint "reuse-conn requires the caller cfg to carry ::db/conn (call inside a transaction)")) (let [email (if factory (factory params) (-> params - (dissoc params) + (dissoc ::reuse-conn ::factory) (check-params)))] - (wrk/submit! {::wrk/task :sendmail - ::wrk/delay 0 - ::wrk/max-retries 4 - ::wrk/priority 200 - ::db/conn conn - ::wrk/params email}))) + (jobs/submit (cond-> (dissoc cfg ::db/conn) + reuse-conn (assoc ::db/conn (::db/conn cfg))) + {::jobs/name :sendmail + ::jobs/delay 0 + ::jobs/max-retries 4 + ::jobs/priority 200 + ::jobs/params email}))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SENDMAIL FN / TASK HANDLER @@ -320,39 +330,44 @@ (declare send-to-logger!) +(defn- sendmail-impl + [cfg params] + (when (contains? cf/flags :smtp) + (let [session (create-smtp-session cfg)] + (with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))] + (.connect ^Transport transport + ^String (::host cfg) + ^String (::port cfg) + ^String (::username cfg) + ^String (::password cfg)) + + (let [^MimeMessage message (create-smtp-message cfg session params)] + (l/dbg :hint "sendmail" + :id (:id params) + :to (:to params) + :subject (str/trim (:subject params))) + + (.sendMessage ^Transport transport + ^MimeMessage message + (.getAllRecipients message)))))) + + (when (contains? cf/flags :log-emails) + (send-to-logger! cfg params))) + (defmethod ig/init-key ::sendmail [_ cfg] - (fn [params] - (when (contains? cf/flags :smtp) - (let [session (create-smtp-session cfg)] - (with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))] - (.connect ^Transport transport - ^String (::host cfg) - ^String (::port cfg) - ^String (::username cfg) - ^String (::password cfg)) + ;; the job handler: [context params]. The smtp deps are closed over + ;; here, and this job has no use for the context. + (fn [_context params] + (sendmail-impl cfg params))) - (let [^MimeMessage message (create-smtp-message cfg session params)] - (l/dbg :hint "sendmail" - :id (:id params) - :to (:to params) - :subject (str/trim (:subject params))) - - (.sendMessage ^Transport transport - ^MimeMessage message - (.getAllRecipients message)))))) - - (when (contains? cf/flags :log-emails) - (send-to-logger! cfg params)))) - -(defmethod ig/assert-key ::handler - [_ params] - (assert (fn? (::sendmail params)) "expected valid sendmail handler")) - -(defmethod ig/init-key ::handler - [_ {:keys [::sendmail]}] - (fn [{:keys [props] :as task}] - (sendmail props))) +(defmethod ig/init-key ::job-def + [_ {sendmail ::sendmail}] + {::jobs/name :sendmail + ::jobs/schema schema:params + ::jobs/handler sendmail + ::jobs/decoder (sm/decoder schema:params sm/json-transformer) + ::jobs/validator (sm/validator schema:params)}) (defn- send-to-logger! [_ email] diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 9be4b1b572..14ad5ffad0 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -19,9 +19,9 @@ [app.db :as db] [app.db.sql :as-alias sql] [app.features.fdata :as fdata] + [app.jobs :as jobs] [app.storage :as sto] [app.util.blob :as blob] - [app.worker :as wrk] [cuerdas.core :as str])) (def sql:snapshots @@ -406,12 +406,12 @@ (assert (uuid? file-id) "missing file-id") (assert (ct/inst? deleted-at) "missing deleted-at") - (wrk/submit! {::db/conn (db/get-connection cfg) - ::wrk/task :delete-object - ::wrk/params {:object :snapshot - :deleted-at deleted-at - :file-id file-id - :id id}}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :snapshot + :deleted-at deleted-at + :file-id file-id + :id id}}) (db/update! cfg :file-change {:deleted-at deleted-at} {:id id :file-id file-id} diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index c5909ca432..4e9ce3bd77 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -18,6 +18,7 @@ [app.http :as-alias http] [app.http.auth :as-alias http.auth] [app.http.session.tasks :as-alias tasks] + [app.jobs :as jobs] [app.main :as-alias main] [app.setup :as-alias setup] [app.setup.clock :as clock] @@ -329,7 +330,7 @@ ;; TASK: SESSION GC ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defmethod ig/assert-key ::tasks/gc +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected valid database pool") (assert (ct/duration? (::tasks/max-age params))) @@ -341,7 +342,7 @@ (throw (IllegalArgumentException. "absolute session max-age must be greater than or equal to idle max-age")))) -(defmethod ig/expand-key ::tasks/gc +(defmethod ig/expand-key ::job-def [k v] (let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age) max-age-absolute (cf/get :auth-token-cookie-max-age-absolute @@ -368,10 +369,25 @@ :deleted result) result)) -(defmethod ig/init-key ::tasks/gc - [_ {:keys [::tasks/max-age ::tasks/max-age-absolute] :as cfg}] - (l/dbg :hint "initializing session gc task" - :max-age max-age - :max-age-absolute max-age-absolute) - (fn [_] - (db/tx-run! cfg collect-expired-tasks))) +(declare execute-session-gc) + +(def schema:session-gc-params + "Params map (no params needed; config-derived only)." + [:map {:closed true}]) + +(defmethod ig/init-key ::job-def + [_ cfg] + {::jobs/name :session-gc + ::jobs/schema schema:session-gc-params + ::jobs/handler + (fn [_context params] + (execute-session-gc cfg params)) + ::jobs/decoder (sm/decoder schema:session-gc-params sm/json-transformer) + ::jobs/validator (sm/validator schema:session-gc-params)}) + +(defn execute-session-gc + "Plain job handler: delete expired http sessions." + ([cfg] (execute-session-gc cfg nil)) + ([cfg _params] + (jobs/heartbeat cfg) + (db/tx-run! cfg collect-expired-tasks))) diff --git a/backend/src/app/jobs.clj b/backend/src/app/jobs.clj new file mode 100644 index 0000000000..849ecb3784 --- /dev/null +++ b/backend/src/app/jobs.clj @@ -0,0 +1,1187 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs + "Public API for the unified jobs substrate. + + Every job is defined as a job-def map returned by the `ig/init-key` of its + module: {::name, ::schema, ::handler, ::decoder, ::validator} — decoder and + validator are precompiled at init time. The registry of job-defs is plain + integrant wiring (::jobs/defs); submit, dispatch and management consume it + by reference. + + Two execution modes are provided: + - `submit` (durable): validates + JSON-encodes params and inserts a row + into the `job` table; the dispatcher/runner machinery does the rest. + - `request` (ephemeral): synchronous request/response over the redis + queues with a reply-key and a dedicated, unbounded connection pool; + external workers answer with `reply`. + + Params payloads are stored as plain JSON (not transit) in the `params` jsonb + column and decoded back to typed Clojure values using the job-def decoder. + + Any job that can run longer than `:jobs-lease` must call `heartbeat` + on its loop, otherwise the dispatcher marks it `aborted` (a system-side + terminal state, like `cancelled` but set by the dispatcher instead of + a user: never retried, kept for triage and reported with an error + log) while its side effects continue. `heartbeat` also accepts an + optional `progress` report, which is stored as a `progress` row of + `job_event`: progress is durable history, not a mutable column, and + needs no Redis. + + Events of a job with a `profile_id` publish a `:job-event` message on the + topic of that profile after the transaction commits, so a client can + follow the job without polling the database. + + Reserved ledger columns (`profile_id`, `error`, `result`, `resource_id`, + `expires_at`) are only written by `submit` (profile and resource + references) and by the terminal writers; `submit` never infers them from + `params`." + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.generic-pool :as gpool] + [app.common.json :as json] + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs.metrics :as metrics] + [app.metrics :as mtx] + [app.msgbus :as mbus] + [app.redis :as rds] + [app.worker :as wrk] + [cuerdas.core :as str] + [integrant.core :as ig]) + (:import + java.lang.AutoCloseable)) + +(set! *warn-on-reflection* true) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HANDLER CONTEXT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:context + "What a handler is allowed to know about the job it is running. The + schema is closed on purpose: a handler must not be able to reach the + whole row, and adding a key here is a decision, not an accident. + + `queue` is deliberately absent: it is a routing detail of the + dispatcher, not a property of the job. `name` already says which job + this is, and a handler has no use for the queue it happened to be + scheduled on. + + The retry keys are absent too. Retry policy belongs to the runner: it + is the one comparing `retry-num` with `max-retries` and deciding + whether to schedule again. A handler has no use for the counter, and + the attempt number would lie anyway: the `noop` retry strategy runs + again without incrementing it, so a durable \"which execution is this\" + number would need a second counter that nothing bounds." + [:map {:closed true + :title "job-context"} + [:id ::sm/uuid] + [:name ::sm/text] + [:label [:maybe ::sm/text]] + ;; technical reference kept for garbage collection + [:resource-id [:maybe ::sm/uuid]]]) + +(def check-context + "Validate a handler context; raises with the malli explanation when it + does not match the closed schema." + (sm/check-fn schema:context)) + +(defn make-context + "Build the handler context from a job row: exactly the four keys a + handler may see, and nothing else. The result is a plain map, not the + database row, and it is not modified afterwards. + + The empty label is the `submit` default for \"no label\", so it becomes + nil here: a handler sees either a label or nothing." + [job] + (check-context {:id (:id job) + :name (:name job) + :label (if (str/blank? (:label job)) nil (:label job)) + :resource-id (:resource-id job)})) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JOB DEFINITIONS (registry) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def schema:job-name + "Canonical job name: a keyword, and only a keyword. Callers pass + `:sendmail`, never \"sendmail\": `submit` and `invoke` reject anything + else. The database still stores a string (`d/name` at the submit + boundary), so `get-job-def` coerces back at that boundary." + :keyword) + +(def ^:private schema:job-def + [:map {:title "job-def"} + [::name schema:job-name] + [::schema any?] + ;; every handler is [context params]. A job-def already closes over its + ;; own dependencies, so nothing else is handed to it: a handler cannot + ;; reach the runner cfg, the pool or any other component. + ;; + ;; A wrapper forwards the context to its implementation only when that + ;; implementation takes it. Most jobs have no use for it, so most + ;; wrappers are (fn [_context params] (execute-X cfg params)) and their + ;; implementation stays [cfg params]. + [::handler ::sm/fn] + [::decoder ::sm/fn] + [::validator ::sm/fn]]) + +(def ^:private schema:job-defs + [:map-of :keyword schema:job-def]) + +;; The worker components name this registry as a bare `::defs` entry in +;; their integrant params schemas, and a bare entry is a registry lookup: +;; without this the backend crashes at boot with +;; `:malli.core/invalid-schema` instead of asserting the config. +(sm/register! ::defs schema:job-defs) + +;; Module-level registry of the job-defs, populated by the ::jobs/defs +;; ig component on init. It exists for the submit call-sites that run +;; inside components that cannot reference `::jobs/defs` by ig/ref +;; (the job-def components themselves are part of the registry; an ig +;; ref would create a wiring cycle). Call-sites that can provide the +;; registry via cfg take precedence over this global one. +(def ^:private defs-registry (atom {})) + +(defn get-defs + "The registry for submit/lookup: the one provided on the cfg has + precedence over the module-level one (used by tests)." + [cfg] + (or (get cfg ::defs) @defs-registry)) + +(def ^:private definitions-validator (sm/validator schema:job-defs)) + +(defmethod ig/assert-key ::defs + [_ defs] + (assert (definitions-validator defs) "expected valid job-defs map") + (doseq [[name job-def] defs] + (when-not (= (d/name name) (d/name (::name job-def))) + (ex/raise :type :assertion + :code :job-def-name-mismatch + :hint "job-def name mismatch" + :job (d/name name) + :expected (d/name (::name job-def)))))) + +(defmethod ig/init-key ::defs + [_ defs] + (reset! defs-registry defs) + (l/inf :hint "job definitions initialized" :jobs (count defs)) + defs) + +(defmethod ig/halt-key! ::defs + [_ defs] + (reset! defs-registry {}) + (l/inf :hint "job definitions halted" :jobs (count defs))) + +(defn get-job-def + "Resolve the job-def for the provided job name; raises if missing. + + Accepts a string as well as a keyword, but only because the runner + resolves from the database row (whose `name` column is text): every + other caller passes the canonical keyword." + [defs name] + (or (get defs (keyword name)) + (ex/raise :type :not-found + :hint "no job definition found" + :code :no-job-definition + :name (d/name name)))) + +(defn decode-params + "Decode the raw JSON params (pgobject or decoded map) into typed params + using the precompiled decoder of the job-def." + [job-def params] + (-> (cond-> params + (db/pgobject? params) + db/decode-json-pgobject) + ((::decoder job-def)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SUBMIT API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private sql:insert-new-job + "insert into job (id, name, tenant, params, queue, label, priority, + max_retries, profile_id, resource_id, created_at, + modified_at, scheduled_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + returning id") + +;; `tenant` is part of the match, not an extra: without it this DELETE +;; reaches the not-started jobs of every other instance sharing the +;; database. +(def ^:private sql:remove-not-started-jobs + "DELETE FROM job + WHERE name=? + AND tenant=? + AND queue=? + AND label=? + AND status = 'new' + AND scheduled_at > ?") + +(def ^:private schema:options + [:map {:title "submit-options"} + [::name schema:job-name] + [::label {:optional true} ::sm/text] + [::delay {:optional true} + [:or ::sm/int ::ct/duration]] + [::queue {:optional true} [:or ::sm/text :keyword]] + [::priority {:optional true} ::sm/int] + [::max-retries {:optional true} ::sm/int] + [::dedupe {:optional true} ::sm/boolean] + ;; Owner of a user-facing job. Its events are published on the msgbus + ;; topic of this profile; internal jobs omit it. + [::profile-id {:optional true} ::sm/uuid] + ;; Technical reference (storage object) kept for garbage collection. It + ;; is a column, never part of `params`, and it is never inferred from it. + [::resource-id {:optional true} ::sm/uuid]]) + +(def check-options + (sm/check-fn schema:options)) + +(defn validate-params + "Validate the params with the precompiled validator of the job-def; on + failure raises with the malli explanation. Shared by `submit` (raw + params) and the runner (decoded params). `invoke` deliberately stays + lenient: it is the in-process escape hatch used by tests (legacy task + params via `run-task!`) and the REPL." + [job-def params] + (when-not ^boolean ((::validator job-def) params) + (sm/check (::schema job-def) params)) + params) + +(defn submit + "Submit a durable job: validates the params with the job-def validator, + encodes them as plain JSON and inserts a row into the `job` table. + Fire-and-forget: returns the job id immediately. + + NOTE: the dedupe DELETE and the INSERT run atomically: joined to + the caller's transaction when the cfg provides `::db/conn`, wrapped + in their own transaction otherwise. Concurrent cross-backend + submissions can, in rare race conditions, produce duplicated 'new' + rows (accepted risk, see prod-infra documentation)." + [cfg {:keys [::params ::name ::delay ::queue ::priority ::max-retries + ::dedupe ::label ::profile-id ::resource-id] + :or {delay 0 queue :default priority 100 max-retries 3 label ""} + :as options}] + + (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + (check-options options) + (let [job-def (get-job-def (get-defs cfg) name) + params (validate-params job-def params) + delay (ct/duration delay) + now (ct/now) + scheduled-at (-> (ct/plus now delay) + (ct/truncate :millisecond)) + ;; The :rollback? testing escape hatch must never persist on a + ;; durable row (the runner would roll everything back yet mark + ;; the job completed); it stays available in-process via + ;; invoke/run-task!. Validation above is untouched. + ;; Duration values are normalized to millis: a Duration object + ;; does not survive JSON encoding (schemas still accept it for + ;; in-process callers). + payload (db/json (-> (dissoc params :rollback?) + (update-vals #(if (ct/duration? %) + (inst-ms %) + %)))) + id (uuid/next) + tenant (cf/get :tenant) + job-name (d/name name) + queue (d/name queue) + ;; Dedupe is best-effort: we delete not-started jobs of this + ;; tenant with the same name/queue/label, then insert. A race + ;; between backends could create duplicates, but this is + ;; acceptable: cross-backend races are rare, jobs are idempotent, + ;; and dedupe is best-effort. + insert! (fn [conn] + (let [deleted (when dedupe + ;; the DELETE below is intentional: + ;; disable the `job` deletion-protection + ;; guard inside this transaction + (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) + (-> (db/exec-one! conn [sql:remove-not-started-jobs + job-name tenant queue label now]) + (db/get-update-count)))] + (l/trc :hint "submit" + :name job-name + :job-id (str id) + :tenant tenant + :queue queue + :label label + :dedupe (boolean dedupe) + :delay (ct/format-duration delay) + :replace (or deleted 0)) + (db/exec-one! conn [sql:insert-new-job id job-name tenant payload queue + label priority max-retries + profile-id resource-id + now now scheduled-at])))] + ;; Both statements always run inside db/tx-run!: joined to the + ;; caller's transaction when the cfg provides a connection, + ;; wrapped in their own otherwise (a failed INSERT can never + ;; orphan a committed DELETE, even on an autocommit caller conn). + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [result (insert! conn)] + (db/after-commit + #(metrics/record-submitted cfg job-name queue)) + result))) + id)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JOB API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- decode-json-col + [row key] + (cond-> row + (db/pgobject? (get row key)) + (assoc key (db/decode-json-pgobject (get row key))))) + +(defn- decode-row + [row] + (decode-json-col row :params)) + +(defn get-job + "Retrieve the job row (with the raw JSON params decoded to a plain map)." + [cfg job-id] + (some-> (db/get* cfg :job {:id job-id}) + (decode-row))) + +(defn get-user-status + "Map the internal job status to the user-facing status." + [status] + (let [status (d/name status)] + (case status + ("new" "scheduled" "retry") "pending" + "running" "running" + "completed" "completed" + ("failed" "cancelled" "aborted") "failed"))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JOB EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; `job_event` is an append-only log: it is the only durable place for +;; progress, and the place where the lifecycle of a job is recorded. There +;; is no Redis key and no mutable column to keep in sync. + +;; mirrors the `job_event.kind` CHECK constraint of migration 0154 +(def ^:private known-event-kinds + #{"start" "progress" "retry" "end"}) + +(def ^:private known-outcomes + ;; mirrors the `job_event` end outcomes. `aborted` is only ever written + ;; by the dispatcher orphan sweep (bulk insert in the same transaction, + ;; see `app.worker.dispatcher/mark-orphan-jobs`): no `app.jobs` writer + ;; emits it. + #{"completed" "failed" "cancelled" "aborted"}) + +(def ^:private known-retry-reasons + #{"backoff" "noop"}) + +(def ^:private max-stage-length 250) + +(def schema:progress + "Progress report of a job: `current` is mandatory, `total` and `stage` are + optional, and no other key is accepted. `stage` is a short human label, + not a place for exception messages or params." + [:map {:closed true + :title "job-progress"} + [:current ::sm/int] + [:total {:optional true} ::sm/int] + [:stage {:optional true} ::sm/text]]) + +(def check-progress + "Validate a progress report against its schema." + (sm/check-fn schema:progress)) + +(defn validate-progress + "Check the range rules a schema cannot express: non-negative `current`, + positive `total`, `current` never greater than `total` and a short + `stage`." + [progress] + (let [progress (check-progress progress)] + (when (neg? (:current progress)) + (ex/raise :type :validation + :code :invalid-progress + :hint "progress current must not be negative" + :progress progress)) + (when-let [total (:total progress)] + (when (or (not (pos? total)) + (> (:current progress) total)) + (ex/raise :type :validation + :code :invalid-progress + :hint "progress total must be positive and not lower than current" + :progress progress))) + (when (and (:stage progress) + (> (count (:stage progress)) max-stage-length)) + (ex/raise :type :validation + :code :invalid-progress + :hint (str "progress stage must not be longer than " + max-stage-length " characters") + :progress progress)) + progress)) + +(def ^:private event-payload-keys + {"start" #{:attempt} + "progress" #{:current :total :stage} + "retry" #{:attempt :reason} + "end" #{:outcome}}) + +(defn- validate-event-payload + "Reject anything the durable log must never store: unknown kinds, unknown + outcomes or retry reasons, extra keys, params or exception text." + [kind payload] + (let [reject (fn [hint details] + (ex/raise :type :validation + :code :invalid-job-event + :hint hint + :kind kind + :details details)) + reject-attempt #(reject "job event attempt must be a non negative integer" + payload) + allowed (get event-payload-keys kind)] + (when-not (contains? known-event-kinds kind) + (reject "unknown job event kind" kind)) + (when (seq (remove allowed (keys payload))) + (reject "job event payload has unexpected keys" (keys payload))) + (case kind + "start" (do (when-not (nat-int? (:attempt payload)) (reject-attempt)) + payload) + "retry" (do (when-not (nat-int? (:attempt payload)) (reject-attempt)) + (when-not (contains? known-retry-reasons (:reason payload)) + (reject "unknown job event retry reason" (:reason payload))) + payload) + "end" (do (when-not (contains? known-outcomes (:outcome payload)) + (reject "unknown job event outcome" (:outcome payload))) + payload) + "progress" (validate-progress payload)))) + +(def ^:private sql:insert-job-event + "INSERT INTO job_event (job_id, kind, payload) + VALUES (?, ?, ?) + RETURNING id, created_at") + +(def ^:private sql:job-event-owner + "SELECT profile_id FROM job WHERE id = ?") + +(defn- notify-event + "Publish a `:job-event` message on the topic of the job profile. Runs + after the commit that inserted the event: a listener that is not + connected never rolls back durable history. Takes the cfg and resolves + the msgbus from it, like `record-terminal` does with the metrics." + [{:keys [::mbus/msgbus]} + {:keys [profile-id job-id event-id kind payload created-at]}] + (mbus/pub! msgbus + :topic profile-id + :message {:type :job-event + :profile-id profile-id + :job-id job-id + :event-id event-id + :kind kind + :payload payload + :created-at created-at})) + +(defn insert-event + "Insert a `job_event` row, then count it and notify after the commit. + + Must be called inside a transaction that owns the job row. When the job + has a `profile_id` the cfg must carry a msgbus: the check happens + before the insert, so a profile job never loses its notification + silently. Jobs without profile store the event and publish nothing. + + The counter also runs after the commit, so a rolled back transaction + never inflates it." + [{:keys [::mbus/msgbus] :as cfg} job-id kind payload] + (let [payload (validate-event-payload kind payload) + connectable (db/get-connectable cfg) + profile-id (:profile-id (db/exec-one! connectable [sql:job-event-owner job-id]))] + (when (and profile-id + (not (mbus/msgbus? msgbus))) + (ex/raise :type :assertion + :code :missing-msgbus + :hint "job events of a profile job require ::mbus/msgbus on the cfg" + :job-id job-id + :profile-id profile-id)) + (let [{:keys [id created-at]} + (db/exec-one! connectable [sql:insert-job-event job-id kind (db/json payload)])] + (db/after-commit #(metrics/record-event cfg)) + (when profile-id + (db/after-commit + #(notify-event cfg {:profile-id profile-id + :job-id job-id + :event-id id + :kind kind + :payload payload + :created-at created-at}))) + {:event-id id :created-at created-at :kind kind :payload payload}))) + +(defn- own-transaction + "A cfg for a job write that must own its transaction. + + A lifecycle write reports on work that already happened elsewhere, so + it never joins the transaction of whoever called it: the `::db/conn` is + dropped and `db/tx-run!` opens its own transaction on its own + connection. Because that transaction joins no other, it owns its + after-commit context too, so the write and its metric commit or vanish + together and a rollback of the surrounding work can undo neither. + + `submit` is the deliberate exception: creating a job is part of the + business unit of work of whoever asks for it." + [cfg] + (dissoc cfg ::db/conn)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HEARTBEAT / PROGRESS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private heartbeat-interval (ct/duration {:seconds 60})) +(def ^:private progress-interval (ct/duration {:seconds 1})) + +(def ^:private prune-threshold 10000) +(def ^:private prune-window (ct/duration {:hours 1})) + +;; Throttle state atoms (public for testing) +(def heartbeats (atom {})) +(def progresses (atom {})) + +(def ^:dynamic *job-id* + "Job id of the job being executed on the current thread. The runner + binds it around handler invocations; handlers call `heartbeat` without + knowing the id. Nil means no job context (in-process `invoke` without a + row), in which case the throttled writes become no-ops." + nil) + +(defn- should-write? + "Throttle gate: true when the last recorded write for `job-id` is older + than `interval` (or when there is none). Maintains a bounded in-memory + registry of the last write time per job. Uses a volatile inside swap! to + communicate the decision — the volatile is dereferenced after the swap + completes, which is safe." + [state-ref job-id now interval] + (let [decision (volatile! false)] + (swap! state-ref + (fn [m] + (let [m (if (> (count m) prune-threshold) + ;; Remove stale entries (older than prune-window) + (into {} + (remove (fn [[_ last-inst]] + (> (- (inst-ms now) (inst-ms last-inst)) + (inst-ms prune-window)))) + m) + m) + last-inst (get m job-id)] + (if (and last-inst + (< (- (inst-ms now) (inst-ms last-inst)) + (inst-ms interval))) + m + (do + (vreset! decision true) + (assoc m job-id now)))))) + @decision)) + +(defn cleanup-throttle + "Remove `job-id` from the heartbeat and progress throttle atoms. + Called by the runner after a job completes (success or failure) to + prevent completed job IDs from accumulating in memory. The prune + fallback in `should-write?` remains as defense-in-depth." + [job-id] + (swap! heartbeats dissoc job-id) + (swap! progresses dissoc job-id)) + +(def ^:private sql:touch-heartbeat + "UPDATE job + SET modified_at = ? + WHERE id = ? + AND status IN ('new', 'scheduled', 'running', 'retry')") + +(def ^:private sql:lock-active-job + "SELECT id FROM job + WHERE id = ? + AND status IN ('new', 'scheduled', 'running', 'retry') + FOR UPDATE") + +(defn- touch-job + [connectable job-id now] + (-> (db/exec-one! connectable [sql:touch-heartbeat now job-id]) + (db/get-update-count))) + +(defn- report-progress + "Insert a progress event on an active job. + + The row is locked first: a concurrent complete, fail or cancel blocks + here, so a progress event can never land on an already terminal job. + Returns 1 when the event was stored. + + Like every lifecycle write it owns its transaction (see `own-transaction`), + so a beat survives the rollback of the work around it." + [cfg job-id progress] + (db/tx-run! (own-transaction cfg) + (fn [{:keys [::db/conn] :as tx-cfg}] + (if (db/exec-one! conn [sql:lock-active-job job-id]) + (do + (insert-event tx-cfg job-id "progress" progress) + 1) + 0)))) + +(defn heartbeat + "Keep a running job alive and, optionally, report its progress. + + Named options: + + - `:job-id` explicit job id; defaults to `::jobs/job-id` on the cfg + and then to the runner-bound `*job-id*`. + - `:progress` optional progress report (see `schema:progress`). + - `::force?` internal: skips only the progress throttle. + + Returns the number of durable writes performed, so 0 means the job is + terminal, has no job context, or the throttle did not allow a write. + + The `modified_at` touch is throttled to ~60s and the progress event to + ~1s: external workers report progress as sparse milestones, while + handlers may beat on every iteration. Both writes go through the + connection pool (`::db/pool` on the cfg), never the caller transaction, + so a beat survives a rollback of the surrounding work. + + Like every other function here the cfg is a cfg: a bare pool or + connection is a caller bug and is not accommodated. + + A progress report never touches a terminal row (beating one would + silently extend its retention window). The payload itself is validated + before any write, on every route: a malformed report is a caller bug, + not a transient failure, so it always raises. Only the insert itself is + forgiving on the handler path, where it is logged and ignored; the + forced path used by the management API propagates it so the external + worker can retry." + [cfg & {:keys [job-id progress] :as options}] + (let [job-id (or job-id (get cfg ::job-id) *job-id*) + ;; a malformed report is a caller bug: never swallow it + progress (some-> progress validate-progress)] + (when (uuid? job-id) + (let [connectable (::db/pool cfg) + now (ct/now) + force? (boolean (::force? options)) + writes (volatile! 0)] + (when (should-write? heartbeats job-id now heartbeat-interval) + (vswap! writes + (touch-job connectable job-id now))) + (when (and (some? progress) + (or force? + (should-write? progresses job-id now progress-interval))) + (let [store #(report-progress cfg job-id progress)] + (if force? + (vswap! writes + (store)) + (try + (vswap! writes + (store)) + (catch Throwable cause + (l/err :hint "unable to persist job progress" + :tenant (cf/get :tenant) + :job-id (str job-id) + :cause cause)))))) + (int @writes))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JOB WRITERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; The only path that changes the status of a job. Every writer changes +;; the row and stores its event in the same transaction, so the durable +;; history can never contradict the row: if the event cannot be stored, +;; the transition is rolled back. A writer that affects no row writes no +;; event and no metric, which is what makes concurrent transitions safe: +;; the first one wins and the losers see zero. +;; +;; Every metric is registered with `db/after-commit` from inside the +;; transaction, next to the event, so it lands on the outermost commit +;; whatever that is: a rolled back transition is never counted. Each +;; writer owns that transaction (see `own-transaction`), so the commit +;; in question is always its own. +;; +;; That callback swallows exceptions, which is why every writer below +;; opens with `(assert (mtx/instance cfg) ...)`. It reads like a +;; redundant assertion because the instance is not used here, but it is +;; the only thing that makes a miswired cfg fail loudly: resolve it +;; inside the callback instead and the row is persisted while the count +;; disappears into a log line. + +(defn encode-result + "Serialize a job result to JSON, dropping unserializable values to nil + with a warning instead of throwing (a throw here would leave the row + stuck in `running` until the orphan lease fires)." + [job-name result] + (try + (db/json result) + (catch Throwable cause + (l/err :hint "unable to serialize job result to JSON" + :job-name (some-> job-name str) + :cause cause) + nil))) + +(defn- record-terminal + "Record the terminal outcome and the total time. Runs after the commit + that made the job terminal, so a rolled back transition is never + counted. Takes the cfg and resolves the metrics instance from it, so + the callback is not tied to an instance read at registration time." + [cfg job outcome] + (when job + (metrics/record-outcome cfg (:name job) (:queue job) outcome) + (when (ct/inst? (:created-at job)) + (metrics/record-total + cfg + (:name job) + (:queue job) + outcome + (- (inst-ms (ct/now)) (inst-ms (:created-at job))))))) + +(def schema:job-error + "Structured error of a job that did not complete. `type` and `code` are + keywords so they decode back from the stored JSON, `hint` is the human + message. The map stays open: these three keys are the contract, and + anything else is a detail the failing component adds for triage." + [:map {:title "job-error"} + [:type ::sm/keyword] + [:code ::sm/keyword] + [:hint ::sm/text]]) + +(def check-job-error + "Validate a job error against its schema; raises with the malli + explanation when it does not match." + (sm/check-fn schema:job-error)) + +(def orphan-error + "Error of a job whose execution process died or stopped reporting. + Nothing is alive to report it, so the dispatcher stores this and marks + the job `aborted` (system-side terminal, never retried) with its `end` + event in the same transaction: the durable state of the job is the + whole story." + (check-job-error {:type :internal + :code :orphan + :hint "job execution lease expired"})) + +(def ^:private decode-job-error-value + (sm/decoder schema:job-error sm/json-transformer)) + +(defn decode-job-error + "Read back a stored `job.error` as a Clojure map, applying the + transformation of `schema:job-error` so `type` and `code` come back as + keywords instead of the strings the database holds." + [error] + (when (some? error) + (decode-job-error-value (cond-> error + (db/pgobject? error) + (db/decode-json-pgobject))))) + +;; --- CLAIM + +(def ^:private sql:claim-job + "UPDATE job + SET status='running', started_at=now(), modified_at=now() + WHERE id=? + AND scheduled_at=? + AND status IN ('new','scheduled','retry') + RETURNING retry_num") + +(defn claim + "Claim a job on behalf of a worker, internal or external, and record the + `start` event. + + Only transitions a pending row (new/scheduled/retry) to `running`, and + requires an exact `scheduled-at` match with the value advertised in the + queue payload, so a stale payload (row rescheduled or claimed in the + meantime) affects 0 rows and must be skipped. Returns the number of + affected rows. + + The event carries `attempt` = `retry_num + 1`: the number of the + attempt this run consumes from the retry budget." + [cfg job-id scheduled-at] + (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + (db/tx-run! (own-transaction cfg) + (fn [{:keys [::db/conn] :as tx-cfg}] + (if-let [{:keys [retry-num]} + (db/exec-one! conn [sql:claim-job job-id scheduled-at])] + (do + (insert-event tx-cfg + job-id "start" {:attempt (inc (long retry-num))}) + 1) + 0)))) + +;; --- RETRY + +(def ^:private sql:retry-job + "UPDATE job + SET status='retry', modified_at=?, scheduled_at=?, retry_num=?, error=? + WHERE id=? + AND status IN ('running','retry') + RETURNING retry_num") + +(defn retry-job + "Schedule the next attempt of a running job and record the `retry` event. + + `next-retry-num` is the counter after the transition and `reason` says + how we got there, one of `:backoff` or `:noop`. A backoff increments the + counter, a noop keeps it, so the event always reports the attempt that + counts against `max-retries`: a noop retry re-reports the same number, + because it consumed no budget. Returns the number of affected rows; a + terminal job is left untouched and writes no event." + [cfg job-id next-retry-num scheduled-at error reason] + (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + (let [job (get-job cfg job-id) + encoded (db/json (check-job-error error))] + (db/tx-run! (own-transaction cfg) + (fn [{:keys [::db/conn] :as tx-cfg}] + (if-let [{:keys [retry-num]} + (db/exec-one! conn [sql:retry-job (ct/now) scheduled-at + next-retry-num encoded job-id])] + (do + (insert-event tx-cfg + job-id "retry" {:attempt (inc (long retry-num)) + :reason (name reason)}) + (db/after-commit + #(metrics/record-retry tx-cfg + (:name job) + (:queue job) + reason)) + 1) + 0))))) + +;; --- COMPLETE + +(def ^:private sql:complete-job + "UPDATE job + SET status='completed', completed_at=?, modified_at=?, result=?, + error=NULL, resource_id=coalesce(resource_id, ?) + WHERE id=? + AND status IN ('running','retry') + RETURNING id") + +(def ^:private sql:lock-job + "SELECT * FROM job + WHERE id = ? + FOR UPDATE") + +(defn- check-resource-id + "Validate a `resource-id` on its own, before any database work." + [resource-id] + (when (some? resource-id) + (when-not (uuid? resource-id) + (ex/raise :type :validation + :code :invalid-resource-id + :hint "resource-id must be a uuid" + :resource-id resource-id)))) + +(defn complete + "Mark a running job as completed and record the `end` event. + + Named options: + + - `:job-id` the job to complete. + - `:result` the JSON-encodable result, nil when the job returns + nothing. + - `:resource-id` optional storage object to associate with the job. A + resource is only ever set, never replaced: passing one + for a job that already has a resource is a validation + error, and a nil value leaves the column untouched. + + The result, the resource, the status and the event share one + transaction, so a job never ends up completed with a resource that was + not stored or an event that is missing. + + Conditional on the non-terminal running/retry states (first-terminal + wins: a row already marked failed/cancelled/aborted — e.g. an orphan + aborted by the dispatcher — is never overwritten) and writes no event. Returns + the number of affected rows. + + The row is read locked inside that same transaction, so the two rules + are applied in a fixed order against the row and not against the + clock: a job that is no longer completable is skipped, and only a job + that is still completable can be refused for its resource. A + concurrent completion waits for the lock and then sees the outcome." + [cfg & {:keys [job-id result resource-id]}] + (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + (check-resource-id resource-id) + (let [n (db/tx-run! (own-transaction cfg) + (fn [{:keys [::db/conn] :as tx-cfg}] + (let [job (some-> (db/exec-one! conn [sql:lock-job job-id]) + decode-row) + encoded (when (some? result) + (encode-result (:name job) result))] + (if-not (contains? #{"running" "retry"} (:status job)) + 0 + (do + (when (and (some? resource-id) + (:resource-id job)) + (ex/raise :type :validation + :code :resource-already-associated + :hint "the job already has a resource, it cannot be replaced" + :job-id job-id + :resource-id (:resource-id job))) + (if (db/exec-one! conn [sql:complete-job (ct/now) (ct/now) + encoded resource-id job-id]) + (do + (insert-event tx-cfg + job-id "end" {:outcome "completed"}) + (db/after-commit + #(record-terminal tx-cfg job :completed)) + 1) + 0))))))] + (cleanup-throttle job-id) + n)) + +;; --- FAIL + +(def ^:private sql:fail-job + "UPDATE job + SET status='failed', modified_at=?, error=? + WHERE id=? + AND status IN ('running','retry') + RETURNING id") + +(defn fail + "Mark a running job as failed and record the `end` event. + + The error must conform to `schema:job-error`; it is validated here, not + only at the RPC boundary, so every path writes the same shape. The + error never reaches the event: `job.error` is where the whole thing + lives, and the event only says the job failed. + + Conditional on the non-terminal running/retry states (first-terminal + wins) and writes no event when it affects no row. Returns the number of + affected rows." + [cfg job-id error] + (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + (let [job (get-job cfg job-id) + encoded (db/json (check-job-error error)) + n (db/tx-run! (own-transaction cfg) + (fn [{:keys [::db/conn] :as tx-cfg}] + (if (db/exec-one! conn [sql:fail-job (ct/now) encoded job-id]) + (do + (insert-event tx-cfg + job-id "end" {:outcome "failed"}) + (db/after-commit + #(record-terminal tx-cfg job :failed)) + 1) + 0)))] + (cleanup-throttle job-id) + n)) + +;; --- CANCEL + +(def ^:private sql:cancel-job + "UPDATE job + SET status='cancelled', modified_at=? + WHERE id=? + AND status IN ('new','scheduled','retry') + RETURNING id") + +(defn cancel + "Cancel a pending job (new/scheduled/retry) and record the `end` event. + + A running or terminal job is left untouched and writes no event. Returns + the number of affected rows." + [cfg job-id] + (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + (let [job (get-job cfg job-id) + n (db/tx-run! (own-transaction cfg) + (fn [{:keys [::db/conn] :as tx-cfg}] + (if (db/exec-one! conn [sql:cancel-job (ct/now) job-id]) + (do + (insert-event tx-cfg + job-id "end" {:outcome "cancelled"}) + (db/after-commit + #(record-terminal tx-cfg job :cancelled)) + 1) + 0)))] + (when (pos? n) + (cleanup-throttle job-id)) + n)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; REQUEST (ephemeral request/response, no row, no dispatcher) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; NOTE (2026-09-17): this path intentionally has no producer yet; it +;; is kept for an upcoming phase. Do not re-raise as dead code until +;; that phase wires a consumer. + +(def ^:private request-command-timeout-margin (ct/duration {:seconds 30})) +(def ^:private reply-expire-seconds 60) + +(def reply-key-prefix "penpot.worker.reply") + +(defn get-request-context + [cfg] + (or (::request-pool cfg) + (ex/raise :type :assertion + :code :missing-request-pool + :hint "missing ::jobs/request-pool on provided cfg"))) + +(defmethod ig/expand-key ::request-pool + [k v] + {k (-> (d/without-nils v) + (assoc ::command-timeout + (ct/plus (cf/get-jobs-request-timeout) + request-command-timeout-margin)))}) + +(def ^:private schema:request-pool + [:map + [::command-timeout ::ct/duration] + ::rds/client + ::mtx/metrics]) + +(defmethod ig/assert-key ::request-pool + [_ cfg] + (sm/check schema:request-pool cfg)) + +(defmethod ig/init-key ::request-pool + [_ {::rds/keys [client] ::mtx/keys [metrics] ::keys [command-timeout]}] + ;; pool without a max size: gpool/get creates a connection when no + ;; idle one is available and never blocks; the in-flight concurrency + ;; is bounded upstream by the RPC concurrency limits. Connections are + ;; created with a command timeout above the per-call request timeout; + ;; the dispose-fn restores it on return to the pool. + {::pool (rds/pool {::rds/client client + ::mtx/metrics metrics} + {:timeout command-timeout}) + ::mtx/metrics metrics}) + +(def ^:private schema:request-options + [:map {:title "request-options"} + [::queue [:or ::sm/text :keyword]] + [::cmd [:or ::sm/text :keyword]] + [::params any?] + [::timeout {:optional true} [:or ::sm/int ::ct/duration]]]) + +(def check-request-options + (sm/check-fn schema:request-options)) + +(defn reply + "Respond to an ephemeral request: push the JSON response to the + reply-key and set a short TTL as a safety net for late replies (a + reply pushed after the caller timeout would otherwise live forever). + Response shape: `{:ok ...}` or `{:error {...}}`. Accepts a connectable + cfg (a redis pool under ::rds/pool)." + [cfg reply-key response] + (rds/run! cfg + (fn [{:keys [::rds/conn]}] + (rds/rpush conn reply-key [(json/encode response)]) + (rds/expire conn reply-key reply-expire-seconds)))) + +(defn request + "Ephemeral request/response (no job row, no dispatcher): pushes a JSON + payload [request-id, reply-key, cmd, params] to the target queue and + blocks on the reply-key with a per-call timeout (defaults to + :jobs-request-timeout). Any per-call override is applied by raising + the pooled connection command timeout for the duration of the call, + which the pool dispose-fn restores on return. + + On success returns the decoded `:ok` payload; an `:error` reply + propagates as an exception; on timeout raises `:request-timeout` and + the reply-key is deleted (in finally, also on error). The connection + is always returned to the pool." + [cfg + {:keys [::queue ::cmd ::params ::timeout] :as options}] + + (check-request-options options) + + (let [context (get-request-context cfg) + pool (::pool context) + tenant (cf/get :tenant) + timeout (or timeout (cf/get-jobs-request-timeout)) + request-id (uuid/next) + reply-key (str/ffmt "%:%:%" reply-key-prefix tenant request-id) + queue-key (wrk/queue-key tenant queue) + payload (json/encode [(str request-id) + reply-key + (d/name cmd) + params])] + + (with-open [^AutoCloseable pooled (gpool/get pool)] + (let [conn @pooled + tpoint (ct/tpoint) + outcome (volatile! :error)] + (try + ;; raise the connection command timeout above the per-call + ;; blpop timeout; the pool dispose-fn restores the default on + ;; return. + (rds/set-timeout conn (ct/plus timeout request-command-timeout-margin)) + + (rds/rpush conn queue-key [payload]) + + (let [[_ reply] (rds/blpop conn [reply-key] timeout)] + (if (nil? reply) + (do + (vreset! outcome :timeout) + (ex/raise :type :timeout + :code :request-timeout + :hint "timeout waiting for the job reply" + :tenant tenant + :queue queue + :timeout timeout)) + (let [response (json/decode reply :key-fn keyword)] + (if-let [error (:error response)] + (do + (vreset! outcome :error) + (ex/raise :type :internal + :code (get error :code) + :hint (or (get error :hint) "request failed") + :tenant tenant + :queue queue + :response response)) + (do + (vreset! outcome :replied) + (get response :ok)))))) + + (finally + ;; the context, not `cfg`: the caller's cfg only carries the + ;; request pool, and the metrics instance comes with it + (metrics/record-request context + @outcome + (inst-ms (tpoint))) + (l/trc :hint "request" + :tenant tenant + :queue (d/name queue) + :cmd (d/name cmd) + :outcome @outcome + :elapsed (inst-ms (tpoint))) + (rds/del conn reply-key) + (rds/reset-timeout conn))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IN-PROCESS INVOCATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn invoke + "Execute a job handler in-process (no row, no dispatch): decodes the + params with the job-def decoder and invokes the handler bound to the + `*job-id*` dynamic (or the provided ::job-id, which makes the + throttled heartbeat/progress writes work against the row). Options: + + {::name :delete-object + ::params {...} ;; raw (JSON-shaped) params + ::defs {...} ;; the ::jobs/defs registry + ::context {...}} ;; optional, validated and delivered as is + ::job-id } ;; optional, only when the row already exists + + Without ::context the handler receives a nil context: there is no row + to describe. ::context and ::job-id are independent, and providing a + context does not turn this into a durable execution: only the job-id + makes heartbeat and progress reach a row. + + Returns the handler result." + [cfg] + (let [name (get cfg ::name)] + (when-not (keyword? name) + (ex/raise :type :validation + :code :invalid-job-name + :hint "job name must be a keyword" + :name name)) + (let [job-def (get-job-def (get-defs cfg) name) + context (some-> (get cfg ::context) check-context) + decoded (decode-params job-def (get cfg ::params))] + (binding [*job-id* (get cfg ::job-id)] + ((::handler job-def) context decoded))))) diff --git a/backend/src/app/jobs/gc.clj b/backend/src/app/jobs/gc.clj new file mode 100644 index 0000000000..04a89a6400 --- /dev/null +++ b/backend/src/app/jobs/gc.clj @@ -0,0 +1,164 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.jobs.gc + "A maintenance cron task for the unified `job` table: + + - expiration: deletes rows with `expires_at` in the past (any status, + user or internal). Expiration is the mechanism for user-facing jobs + and for jobs carrying volatile resources. + - retention: deletes internal terminal rows (no profile, status in + completed/failed/cancelled/aborted) older than the `:jobs-retention` delay. + The dormant legacy `task` table is cleaned by the parallel legacy + version until the next release, which is what allows PRE and HOURLY to + run side by side on the same database and the deploy to roll back. + User-facing terminal rows (profile_id NOT NULL) are + NOT swept by retention: they are governed by `expires_at` (or stay + as ledger history). + + Rows carrying a `resource_id` touch their storage object (touched_at + set in the same transaction, mirroring `sto/touch-object!`) so + `storage-gc-touched` reclaims it: deleting the job row does not touch + the `storage_object` (ON DELETE SET NULL only fires when the object + row is deleted, not the job row), and gc-touched only analyzes objects + already marked with `touched_at` — without the explicit touch the + object would be orphaned. + + Each batch deletes and touches in its own transaction: a crash + leaves the remaining batches for the next tick, and an object can + never lose its referencing row without being marked for reclaim. + + `job` carries a deletion-protection trigger, so each batch disables + it first (`SET LOCAL rules.deletion_protection TO off`): the GC is + the intended deleter, and the guard only exists so a stray + `DELETE FROM job` cannot drop a row without touching its object." + (:require + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.metrics :as metrics] + [integrant.core :as ig])) + +(def ^:private sql:touch-objects + "UPDATE storage_object + SET touched_at = ? + WHERE id = ANY(?::uuid[])") + +(def ^:private gc-batch-size 1000) + +(def ^:private sql:delete-expired-jobs + "DELETE FROM job + WHERE id IN (SELECT id + FROM job + WHERE expires_at < now() + AND status NOT IN ('running', 'retry') + LIMIT ?) + RETURNING resource_id") + +(def ^:private sql:delete-retained-jobs + "DELETE FROM job + WHERE id IN (SELECT id + FROM job + WHERE status IN ('completed', 'failed', 'cancelled', 'aborted') + AND profile_id IS NULL + AND modified_at < now() - ?::interval + LIMIT ?) + RETURNING resource_id") + +(defn- touch-resources + [conn resource-ids] + (if (seq resource-ids) + (-> (db/exec-one! conn [sql:touch-objects (ct/now) + (db/create-array conn "uuid" resource-ids)]) + (db/get-update-count)) + 0)) + +(defn- delete-jobs + [cfg kind sql & params] + (loop [deleted 0 + touched 0] + (let [[rows touched-now] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) + (let [rows (db/exec! conn (conj (into [sql] params) + gc-batch-size)) + resource-ids (into [] (keep :resource-id) rows) + touched (touch-resources conn resource-ids)] + (db/after-commit + #(do + (metrics/record-gc-rows + cfg kind :deleted (count rows)) + (metrics/record-gc-rows + cfg kind :touched touched))) + [rows touched]))) + deleted' (+ deleted (count rows)) + touched' (+ touched touched-now)] + (if (< (count rows) gc-batch-size) + [deleted' touched'] + (do (jobs/heartbeat cfg) + (recur deleted' touched')))))) + +(declare execute-jobs-gc) + +(def schema:jobs-gc-params + "min-age: duration object, integer millis or duration string + in-process; integer millis or duration string over the job pipeline + (a duration object does not survive JSON encoding). The handler + always normalizes with ct/duration." + [:map + [:min-age {:optional true} [:or :int :string ::ct/duration]]]) + +(defmethod ig/init-key ::job-def + [_ cfg] + {::jobs/name :jobs-gc + ::jobs/schema schema:jobs-gc-params + ::jobs/handler (fn [_context params] + (execute-jobs-gc cfg params)) + ::jobs/decoder (sm/decoder schema:jobs-gc-params sm/json-transformer) + ::jobs/validator (sm/validator schema:jobs-gc-params)}) + +(defn execute-jobs-gc + "Plain job handler: delete expired rows (expires_at) and retained + internal terminal rows, marking the storage resources of the deleted + rows as touched. + + Deletes run in bounded batches of 1000 rows, each batch in its own + transaction, with a heartbeat between batches so long sweeps neither + spike the WAL nor outrun the job lease." + [cfg params] + (let [min-age + (ct/duration (or (:min-age params) + (cf/get-jobs-retention))) + + expired-tpoint + (ct/tpoint) + + [deleted-expired touched-expired] + (delete-jobs cfg :expired sql:delete-expired-jobs) + + retained-tpoint + (ct/tpoint) + + [deleted-retained touched-retained] + (delete-jobs cfg :retained sql:delete-retained-jobs + (db/interval min-age))] + + (metrics/record-gc-duration cfg :expired (inst-ms (expired-tpoint))) + (metrics/record-gc-duration cfg :retained (inst-ms (retained-tpoint))) + (l/dbg :hint "finished" + :deleted-expired deleted-expired + :touched-expired touched-expired + :deleted-retained deleted-retained + :touched-retained touched-retained) + {:deleted-expired deleted-expired + :touched-expired touched-expired + :deleted-retained deleted-retained + :touched-retained touched-retained})) + diff --git a/backend/src/app/jobs/metrics.clj b/backend/src/app/jobs/metrics.clj new file mode 100644 index 0000000000..a1dd8a2343 --- /dev/null +++ b/backend/src/app/jobs/metrics.clj @@ -0,0 +1,248 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.jobs.metrics + "Metric names, labels and the jobs backlog sampler. + + Labels are bounded by construction, not by allowlist: every value + recorded here comes from a fixed call site (a job name from the + registry, a queue from submit, an outcome from the lifecycle + writers), never from params, IDs or exception text. A new value + showing up in Prometheus means new code, which is exactly what you + want to see: folding it into some other bucket would only hide it. + + This namespace is intentionally free of job handlers. Callers pass only + values already known at their boundary; IDs, props and exception text never + become metric labels." + (:require + [app.common.data :as d] + [app.common.logging :as l] + [app.common.schema :as sm] + [app.db :as db] + [app.metrics :as mtx] + [cuerdas.core :as str] + [integrant.core :as ig] + [promesa.exec :as px])) + +(set! *warn-on-reflection* true) + +(defn queue-label + "Coerce a queue to its label: lowercase, with \"other\" only when there + is no queue at all. A new queue shows up as-is: that means new code." + [queue] + (str/lower (mtx/label queue "other"))) + +;; Job names go to the metric as-is: the registry is fixed (one job-def +;; per name, wired in `app.main`), so cardinality is bounded by it. +;; Anything without a name (nil) still falls back to "other". +(defn- name-label [name] + (mtx/label name "other")) + +(defn- gc-kind-label [kind] + (str/lower (or (some-> kind d/name) "other"))) + +(defn- outcome-label [outcome] + (str/lower (or (some-> outcome d/name) "failed"))) + +(defn- retry-reason-label [reason] + (str/lower (or (some-> reason d/name) "backoff"))) + +(defn- stage-label [stage] + (str/lower (or (some-> stage d/name) "execution"))) + +(defn- record-value [metrics id labels value] + (mtx/run! metrics :id id :labels labels :val value)) + +(defn- record-count [metrics id labels amount] + (mtx/run! metrics :id id :labels labels :inc amount)) + +(defn record-submitted + [cfg name queue] + (record-count (mtx/instance cfg) :jobs-submitted + [(name-label name) (queue-label queue)] + 1)) + +(defn record-dispatched + [cfg name queue amount] + (record-count (mtx/instance cfg) :jobs-dispatched + [(name-label name) (queue-label queue)] + amount)) + +(defn record-outcome + [cfg name queue outcome] + (record-count (mtx/instance cfg) :jobs-completed + [(name-label name) (queue-label queue) + (outcome-label outcome)] + 1)) + +(defn record-retry + [cfg name queue reason] + (record-count (mtx/instance cfg) :jobs-retries + [(name-label name) (queue-label queue) + (retry-reason-label reason)] + 1)) + +(defn record-event + "Total number of job events stored. No labels: the interesting split is + `kind`, and grouping a growing append-only table to build a metric is not + worth the query on the write path." + [cfg] + (record-count (mtx/instance cfg) :jobs-events-total [] 1)) + +(defn record-orphan + [cfg queue] + (record-count (mtx/instance cfg) :jobs-orphaned + [(queue-label queue)] + 1)) + +(defn record-rescheduled + [cfg queue] + (record-count (mtx/instance cfg) :jobs-rescheduled + [(queue-label queue)] + 1)) + +(defn record-queue-wait + [cfg name queue millis] + (record-value (mtx/instance cfg) :jobs-queue-wait-timing + [(name-label name) (queue-label queue)] + (max 0 (long millis)))) + +(defn record-execution + [cfg name queue millis] + (record-value (mtx/instance cfg) :jobs-execution-timing + [(name-label name) (queue-label queue)] + (max 0 (long millis)))) + +(defn record-legacy-execution + "Histogram `penpot_tasks_timing`, from the pre-jobs `task` queue. + + It only carries the job name label, with no queue to tell jobs of the + same name apart. Use `record-execution`, which adds the queue label, + for anything new. Kept exported only so the dashboards and the alerts + that already read it keep working: like every other jobs metric, + the label is the raw job name." + [cfg name millis] + (record-value (mtx/instance cfg) :tasks-timing + [(mtx/label name "other")] + (max 0 (long millis)))) + +(defn record-total + [cfg name queue outcome millis] + (record-value (mtx/instance cfg) :jobs-total-timing + [(name-label name) (queue-label queue) + (outcome-label outcome)] + (max 0 (long millis)))) + +(defn record-dispatcher-batch + [cfg stage outcome millis] + (record-value (mtx/instance cfg) :jobs-dispatcher-timing + [(stage-label stage) (outcome-label outcome)] + (max 0 (long millis)))) + +(defn record-dispatcher-size + [cfg queue size] + (record-value (mtx/instance cfg) :jobs-dispatcher-batch-size + [(queue-label queue)] + size)) + +(defn record-gc-rows + [cfg kind action amount] + (record-count (mtx/instance cfg) :jobs-gc-rows + [(gc-kind-label kind) + (str/lower (or (some-> action d/name) "deleted"))] + amount)) + +(defn record-gc-duration + [cfg kind millis] + (record-value (mtx/instance cfg) :jobs-gc-timing + [(gc-kind-label kind)] + (max 0 (long millis)))) + +(defn record-cron + [cfg outcome reason] + (record-count (mtx/instance cfg) :jobs-cron-total + [(str/lower (or (some-> outcome d/name) "error")) + (str/lower (or (some-> reason d/name) "none"))] + 1)) + +(defn record-request + [cfg outcome millis] + (let [outcome (str/lower (or (some-> outcome d/name) "error")) + metrics (mtx/instance cfg)] + (record-count metrics :jobs-requests-total [outcome] 1) + (record-value metrics :jobs-request-timing [outcome] + (max 0 (long millis))))) + +(def ^:private backlog-statuses + ["new" "scheduled" "running" "retry" "completed" "failed" "cancelled" "aborted"]) + +(def ^:private sql:backlog + "SELECT status, count(*) AS n + FROM job + GROUP BY status") + +(def ^:private sql:oldest-pending + "SELECT coalesce(extract(epoch FROM (now() - min(created_at))), 0) AS age + FROM job + WHERE status IN ('new', 'scheduled', 'retry')") + +(defn sample-backlog + "Update the current job backlog gauges. The query is intentionally small + and runs periodically rather than from the worker hot path." + [{:keys [::db/pool] :as cfg}] + (let [rows (db/exec! pool [sql:backlog]) + by-id (into {} (map (juxt :status #(or (:n %) 0))) rows) + age (-> (db/exec-one! pool [sql:oldest-pending]) + :age + (max 0) + (double)) + metrics (mtx/instance cfg)] + (doseq [status backlog-statuses] + (record-value metrics :jobs-backlog [status] (get by-id status 0))) + (record-value metrics :jobs-oldest-pending-age [] age) + age)) + +(defn- sample-jobs-metrics [cfg] + (try + (sample-backlog cfg) + (catch Throwable cause + (l/warn :hint "unable to sample jobs metrics" :cause cause)))) + +(def ^:private sample-interval-ms 30000) + +(defn create-sampler + "Create the daemon scheduler used by the jobs metrics component." + [cfg] + (let [scheduler (px/scheduled-executor + :parallelism 1 + :factory (px/thread-factory :prefix "penpot/jobs-metrics/" + :daemon true)) + sample (fn sample [] + (try + (sample-jobs-metrics cfg) + (finally + (px/schedule scheduler sample-interval-ms sample))))] + (px/schedule scheduler 0 sample) + scheduler)) + +(def ^:private schema:sampler + [:map + ::db/pool + ::mtx/metrics]) + +(defmethod ig/assert-key ::sampler + [_ cfg] + (assert (sm/check schema:sampler cfg))) + +(defmethod ig/init-key ::sampler + [_ {:keys [::db/pool] :as cfg}] + (if (db/read-only? pool) + (l/warn :hint "not started (db is read-only)") + (create-sampler cfg))) + +(defmethod ig/halt-key! ::sampler + [_ sampler] + (some-> sampler px/shutdown-now)) diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 085d402faf..7a7d95511a 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -13,19 +13,20 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.transit :as t] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.email :as email] [app.http :as-alias http] [app.http.access-token :as-alias actoken] + [app.jobs :as jobs] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] [app.setup :as-alias setup] [app.util.inet :as inet] [app.util.services :as-alias sv] - [app.worker :as wrk] [cuerdas.core :as str] [yetti.request :as yreq])) @@ -295,19 +296,24 @@ :else label) dedupe? (boolean (and batch-key batch-timeout))] - (wrk/submit! (-> cfg - (assoc ::wrk/task :process-webhook-event) - (assoc ::wrk/queue :webhooks) - (assoc ::wrk/max-retries 0) - (assoc ::wrk/delay (or batch-timeout 0)) - (assoc ::wrk/dedupe dedupe?) - (assoc ::wrk/label label) - (assoc ::wrk/params (-> event - (d/without-qualified) - (dissoc :source) - (dissoc :context) - (dissoc :ip-addr) - (dissoc :type))))))) + (jobs/submit cfg + {::jobs/name :process-webhook-event + ::jobs/queue :webhooks + ::jobs/max-retries 0 + ::jobs/delay (or batch-timeout 0) + ::jobs/dedupe dedupe? + ::jobs/label label + ;; The event travels as an opaque transit blob: + ;; transit preserves the instant/UUID/set types + ;; that plain JSON props cannot carry. + ::jobs/params {:event-blob + (t/encode-str + (-> event + (d/without-qualified) + (dissoc :source) + (dissoc :context) + (dissoc :ip-addr) + (dissoc :type)))}}))) event) (defn submit* diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index b5190cff2b..cf4e12e6b7 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -10,9 +10,11 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.transit :as t] + [app.common.uri :as u] [app.config :as cf] [app.db :as db] [app.http.client :as http] + [app.jobs :as jobs] [app.nitrate :as nitrate] [app.setup :as-alias setup] [integrant.core :as ig] @@ -161,46 +163,56 @@ (mark-archived! cfg rows) (count events)))))))) -(def ^:private schema:handler-params +(declare execute-audit-log-archive) + +(def schema:audit-log-archive-params + "Optional overrides for the repl invocation defaults." [:map ::db/pool ::setup/shared-keys ::http/client - [:app.nitrate/client {:optional true} [:maybe :map]]]) + [:app.nitrate/client {:optional true} [:maybe :map]] + [:enabled {:optional true} :boolean] + [:uri {:optional true} ::sm/uri]]) -(defmethod ig/assert-key ::handler - [_ params] - (assert (sm/valid? schema:handler-params params) "valid params expected for handler")) - -(defmethod ig/init-key ::handler +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props] :as params}] - ;; NOTE: this let allows overwrite default configured values from - ;; the repl, when manually invoking the task. Prefer setting a send - ;; flag (:admin-console and/or :nexus); :enabled alone - ;; marks chunks without shipping. `invoke!` passes params under - ;; `:props`; direct REPL calls may pass a flat map. - (let [props (or props params) - enabled (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console) - (:enabled props false)) + {::jobs/name :audit-log-archive + ::jobs/schema schema:audit-log-archive-params + ::jobs/handler + (fn [_context params] + (execute-audit-log-archive cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-archive-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-archive-params)}) - uri (cf/get :audit-log-archive-uri) - uri (or uri (:uri props)) - cfg (assoc cfg ::uri uri)] - - (when (and (contains? cf/flags :nexus) - (not uri)) - (ex/raise :type :internal - :code :task-not-configured - :hint "archive task not configured, missing uri")) - - (when enabled - (loop [total 0] - (if-let [n (archive-events! cfg)] - (do - (px/sleep 100) - (recur (+ total ^long n))) - - (when (pos? total) - (l/dbg :hint "events archived" :total total)))))))) +(defn execute-audit-log-archive + "Plain job handler: archive the accumulated audit events in chunks + (heartbeat per iteration: the sent chunk batches can be long)." + [cfg params] + ;; NOTE: this let allows overwrite default configured values from + ;; the repl, when manually invoking the task. Prefer setting a send + ;; flag (:admin-console and/or :nexus); :enabled alone + ;; marks chunks without shipping. + (let [enabled (or (contains? cf/flags :nexus) + (contains? cf/flags :admin-console) + (:enabled params false)) + uri (cf/get :audit-log-archive-uri) + uri (or uri (:uri params)) + ;; Normalize to an uri object; params may carry a plain string + ;; on direct invocations (validation only applies on submit!). + uri (u/uri uri) + cfg (assoc cfg ::uri uri)] + (when (and (contains? cf/flags :nexus) + (not uri)) + (ex/raise :type :internal + :code :task-not-configured + :hint "archive task not configured, missing uri")) + (when enabled + (loop [total 0] + (if-let [n (archive-events! cfg)] + (do + (jobs/heartbeat cfg) + (px/sleep 100) + (recur (+ total ^long n))) + (when (pos? total) + (l/dbg :hint "events archived" :total total))))))) diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj index 6fbd1710b4..65cd5bec7f 100644 --- a/backend/src/app/loggers/audit/gc_task.clj +++ b/backend/src/app/loggers/audit/gc_task.clj @@ -7,7 +7,9 @@ (ns app.loggers.audit.gc-task (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.db :as db] + [app.jobs :as jobs] [integrant.core :as ig])) (def ^:private sql:clean-archived @@ -21,11 +23,24 @@ (l/debug :hint "delete archived audit log entries" :deleted result) result)) -(defmethod ig/assert-key ::handler - [_ params] - (assert (db/pool? (::db/pool params)) "valid database pool expected")) +(declare execute-audit-log-gc) -(defmethod ig/init-key ::handler +(def schema:audit-log-gc-params + "Params map (no params needed; config-derived only)." + [:map {:closed true}]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [_] - (clean-archived! cfg))) + {::jobs/name :audit-log-gc + ::jobs/schema schema:audit-log-gc-params + ::jobs/handler + (fn [_context params] + (execute-audit-log-gc cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-gc-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-gc-params)}) + +(defn execute-audit-log-gc + "Plain job handler: delete the archived audit log entries." + ([cfg] (execute-audit-log-gc cfg nil)) + ([cfg _params] + (clean-archived! cfg))) diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index be4ad26503..4b79444604 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -96,12 +96,11 @@ "Convert the log record into a report object and persist it on the database" [{:keys [::db/pool]} {:keys [::l/id] :as record}] (try - (let [uri (cf/get :public-uri) - report (-> record log-record->report d/without-nils)] + (let [report (-> record log-record->report d/without-nils)] (l/dbg :hint "registering error on database" :id (str id) :src "logging" - :uri (str uri "/dbg/error/" id)) + :uri (cf/get-public-uri "dbg/error/" (str id))) (persist-on-database! pool id 3 report)) (catch Throwable cause (l/warn :hint "unexpected exception on database error logger" :cause cause)))) @@ -139,12 +138,11 @@ "Convert the log record into a report object and persist it on the database" [{:keys [::db/pool]} {:keys [id] :as event}] (try - (let [uri (cf/get :public-uri) - report (-> event audit-event->report d/without-nils)] + (let [report (-> event audit-event->report d/without-nils)] (l/dbg :hint "registering error on database" :id (str id) :src "audit-log" - :uri (str uri "/dbg/error/" id)) + :uri (cf/get-public-uri "dbg/error/" (str id))) (persist-on-database! pool id 4 report)) (catch Throwable cause (l/warn :hint "unexpected exception on database error logger" :cause cause)))) @@ -172,18 +170,17 @@ :value (pp/pprint-str result :length 50) :tenant (cf/get :tenant) :version (:full cf/version) - :href (str (cf/get :public-uri))})) + :href (cf/get-public-uri)})) (defn- handle-rlimit-event "Convert the log record into a report object and persist it on the database" [{:keys [::db/pool]} {:keys [::rlimit/id] :as event}] (try - (let [uri (cf/get :public-uri) - report (-> event rlimit-event->report d/without-nils)] + (let [report (-> event rlimit-event->report d/without-nils)] (l/dbg :hint "registering rate limit rejection" :id (str id) :src "rlimit" - :uri (str uri "/dbg/error/" id)) + :uri (cf/get-public-uri "dbg/error/" (str id))) (persist-on-database! pool id 5 report)) (catch Throwable cause (l/warn :hint "unexpected exception on database error logger" :cause cause)))) diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index 31820cb6e0..38a75b8546 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -11,7 +11,6 @@ [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.pprint :as pp] - [app.common.uri :as u] [app.config :as cf] [app.http.client :as http] [app.loggers.audit :as audit] @@ -28,7 +27,7 @@ (let [type (get report :type) text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n" (when id - (str (u/join (cf/get :public-uri) "/dbg/error/" id) " ")) + (str (cf/get-public-uri "dbg/error/" id) " ")) (when-let [pid (:profile-id report)] (if (uuid? pid) diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index 83df9825cc..13e9d65d05 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -10,14 +10,15 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.common.transit :as t] [app.common.uri :as uri] [app.config :as cf] [app.db :as db] [app.http.client :as http] + [app.jobs :as jobs] [app.loggers.audit :as audit] - [app.worker :as wrk] [clojure.data.json :as json] [cuerdas.core :as str] [integrant.core :as ig])) @@ -60,35 +61,54 @@ (some->> (:project-id props) (lookup-webhooks-by-project pool)) (some->> (:file-id props) (lookup-webhooks-by-file pool)))) -(defmethod ig/assert-key ::process-event-handler +(defmethod ig/assert-key ::process-webhook-event-job-def [_ params] (assert (db/pool? (::db/pool params)) "expect valid database pool") (assert (http/client? (::http/client params)) "expect valid http client")) -(defmethod ig/init-key ::process-event-handler +(defn process-event-impl + [cfg {:keys [event-blob]}] + ;; The blob carries the original typed event; decode it once for the + ;; lookups below and forward it untouched so deliveries keep the + ;; exact instant/UUID/set types of the legacy worker. + (let [props (t/decode-str event-blob) + items (lookup-webhooks cfg props) + event {:profile-id (:profile-id props) + :name "webhook" + :type "trigger" + :props {:name (get props :name) + :event-id (get props :id) + :total-affected (count items)}}] + + (audit/insert cfg event) + + (when items + (l/trc :hint "webhooks found for event" :total (count items)) + (db/tx-run! cfg (fn [cfg] + (doseq [item items] + (jobs/submit cfg + {::jobs/name :run-webhook + ::jobs/queue :webhooks + ::jobs/max-retries 3 + ::jobs/params {:event event-blob + :config (select-keys item [:id :uri :mtype])}}))))))) + + +(def schema:process-webhook-event-params + "The audited event travels as an opaque transit blob: transit + preserves the instant/UUID/set types that plain JSON props cannot + carry, so no per-key type declarations are needed." + [:map [:event-blob :string]]) + +(defmethod ig/init-key ::process-webhook-event-job-def [_ cfg] - (fn [{:keys [props] :as task}] - - (let [items (lookup-webhooks cfg props) - event {:profile-id (:profile-id props) - :name "webhook" - :type "trigger" - :props {:name (get props :name) - :event-id (get props :id) - :total-affected (count items)}}] - - (audit/insert cfg event) - - (when items - (l/trc :hint "webhooks found for event" :total (count items)) - (db/tx-run! cfg (fn [cfg] - (doseq [item items] - (wrk/submit! (-> cfg - (assoc ::wrk/task :run-webhook) - (assoc ::wrk/queue :webhooks) - (assoc ::wrk/max-retries 3) - (assoc ::wrk/params {:event props - :config item})))))))))) + {::jobs/name :process-webhook-event + ::jobs/schema schema:process-webhook-event-params + ::jobs/handler + (fn [_context params] + (process-event-impl cfg params)) + ::jobs/decoder (sm/decoder schema:process-webhook-event-params sm/json-transformer) + ::jobs/validator (sm/validator schema:process-webhook-event-params)}) ;; --- RUN (declare interpret-exception) @@ -98,17 +118,17 @@ {:key-fn str/camel :indent true}) -(defmethod ig/assert-key ::run-webhook-handler +(defmethod ig/assert-key ::run-webhook-job-def [_ params] (assert (db/pool? (::db/pool params)) "expect valid database pool") (assert (http/client? (::http/client params)) "expect valid http client")) -(defmethod ig/expand-key ::run-webhook-handler +(defmethod ig/expand-key ::run-webhook-job-def [k v] {k (merge {::max-errors 3} (d/without-nils v))}) -(defmethod ig/init-key ::run-webhook-handler - [_ {:keys [::db/pool ::max-errors] :as cfg}] +(defn run-webhook-impl + [{:keys [::db/pool ::max-errors] :as cfg} props] (letfn [(update-webhook! [whook err] (if err (let [sql [(str "update webhook " @@ -137,39 +157,68 @@ :req-data (db/tjson req) :rsp-data (db/tjson rsp)}))] - (fn [{:keys [props] :as task}] - (let [event (:event props) - whook (:config props) + (let [event (t/decode-str (:event props)) + whook (:config props) - body (case (:mtype whook) - "application/json" (json/write-str event json-write-opts) - "application/transit+json" (t/encode-str event) - "application/x-www-form-urlencoded" (uri/map->query-string event))] + body (case (:mtype whook) + "application/json" (json/write-str event json-write-opts) + "application/transit+json" (t/encode-str event) + "application/x-www-form-urlencoded" (uri/map->query-string event))] - (l/dbg :hint "run webhook" - :event-name (:name event) - :webhook-id (str (:id whook)) - :webhook-uri (:uri whook) - :webhook-mtype (:mtype whook)) + (l/dbg :hint "run webhook" + :event-name (:name event) + :webhook-id (str (:id whook)) + :webhook-uri (:uri whook) + :webhook-mtype (:mtype whook)) - (let [req {:uri (:uri whook) - :headers {"content-type" (:mtype whook) - "user-agent" (str/ffmt "penpot/%" (:main cf/version))} - :timeout (ct/duration "4s") - :method :post - :body body}] - (try - (let [rsp (http/req cfg req {:response-type :input-stream :sync? true}) - err (interpret-response rsp)] - (report-delivery! whook req rsp err) - (update-webhook! whook err)) - (catch Throwable cause - (let [err (interpret-exception cause)] - (report-delivery! whook req nil err) - (update-webhook! whook err) - (when (= err "unknown") - (l/err :hint "unknown error on webhook request" - :cause cause)))))))))) + (let [req {:uri (:uri whook) + :headers {"content-type" (:mtype whook) + "user-agent" (str/ffmt "penpot/%" (:main cf/version))} + :timeout (ct/duration "4s") + :method :post + :body body}] + (try + (let [rsp (http/req cfg req {:response-type :input-stream :sync? true}) + err (interpret-response rsp)] + (report-delivery! whook req rsp err) + (update-webhook! whook err)) + (catch Throwable cause + (let [err (interpret-exception cause)] + (report-delivery! whook req nil err) + (update-webhook! whook err) + (when (= err "unknown") + (l/err :hint "unknown error on webhook request" + :cause cause))))))))) + + +(def schema:run-webhook-config + "Subset of the webhook row consumed by the run-webhook job. The uri + stays text on purpose: configs come straight from the database and + submit! validates raw params without coercion." + [:map + [:id ::sm/uuid] + [:uri ::sm/text] + [:mtype ::sm/text]]) + +(def schema:run-webhook-params + "The event travels as the original transit blob inside the JSON job + props: process-webhook-event receives the transit-encoded audited + event and forwards it untouched, and the handler transit-decodes it + before delivery. This keeps the wire payload identical to the legacy + worker for every mtype, whatever shape the event has." + [:map + [:event :string] + [:config schema:run-webhook-config]]) + +(defmethod ig/init-key ::run-webhook-job-def + [_ cfg] + {::jobs/name :run-webhook + ::jobs/schema schema:run-webhook-params + ::jobs/handler + (fn [_context params] + (run-webhook-impl cfg params)) + ::jobs/decoder (sm/decoder schema:run-webhook-params sm/json-transformer) + ::jobs/validator (sm/validator schema:run-webhook-params)}) (defn interpret-response [{:keys [status] :as response}] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 806ca9a04c..419dd88175 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -23,8 +23,10 @@ [app.http.link-preview :as-alias http.link-preview] [app.http.management :as mgmt] [app.http.session :as session] - [app.http.session.tasks :as-alias session.tasks] [app.http.websocket :as http.ws] + [app.jobs :as-alias jobs] + [app.jobs.gc :as-alias jobs.gc] + [app.jobs.metrics :as jobs-metrics] [app.loggers.webhooks :as-alias webhooks] [app.metrics :as-alias mtx] [app.metrics.definition :as-alias mdef] @@ -97,7 +99,12 @@ :tasks-timing {::mdef/name "penpot_tasks_timing" - ::mdef/help "Background tasks timing (milliseconds)." + ;; Deprecated: superseded by `penpot_jobs_execution_ms`, which adds a + ;; queue label. Kept only for existing dashboards and alerts. The help + ;; text is what someone reading /metrics sees, so it is where the + ;; redirect belongs: the name and the labels stay untouched, so no + ;; panel or alert breaks. + ::mdef/help "Deprecated: use penpot_jobs_execution_ms, which adds a queue label." ::mdef/labels ["name"] ::mdef/type :histogram} @@ -225,7 +232,126 @@ :http-connector-errors-total {::mdef/name "penpot_http_connector_errors_total" ::mdef/help "Total number of handler errors in the http listener." - ::mdef/type :counter}}) + ::mdef/type :counter} + + :jobs-submitted + {::mdef/name "penpot_jobs_submitted_total" + ::mdef/help "Total number of durable jobs submitted." + ::mdef/labels ["name" "queue"] + ::mdef/type :counter} + + :jobs-dispatched + {::mdef/name "penpot_jobs_dispatched_total" + ::mdef/help "Total number of jobs pushed to a worker queue." + ::mdef/labels ["name" "queue"] + ::mdef/type :counter} + + :jobs-completed + {::mdef/name "penpot_jobs_completed_total" + ::mdef/help "Total number of durable jobs reaching a terminal state." + ::mdef/labels ["name" "queue" "outcome"] + ::mdef/type :counter} + + :jobs-retries + {::mdef/name "penpot_jobs_retries_total" + ::mdef/help "Total number of durable jobs rescheduled for retry." + ::mdef/labels ["name" "queue" "reason"] + ::mdef/type :counter} + + :jobs-events-total + {::mdef/name "penpot_jobs_events_total" + ::mdef/help "Total number of job events stored." + ::mdef/type :counter} + + :jobs-orphaned + {::mdef/name "penpot_jobs_orphaned_total" + ::mdef/help "Total number of running jobs marked as orphaned." + ::mdef/labels ["queue"] + ::mdef/type :counter} + + :jobs-rescheduled + {::mdef/name "penpot_jobs_rescheduled_total" + ::mdef/help "Total number of scheduled jobs recovered after transit loss." + ::mdef/labels ["queue"] + ::mdef/type :counter} + + :jobs-queue-wait-timing + {::mdef/name "penpot_jobs_queue_wait_ms" + ::mdef/help "Time from job scheduling to worker claim (milliseconds)." + ::mdef/labels ["name" "queue"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000 600000]} + + :jobs-execution-timing + {::mdef/name "penpot_jobs_execution_ms" + ::mdef/help "Time spent executing a job handler (milliseconds)." + ::mdef/labels ["name" "queue"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000 600000]} + + :jobs-total-timing + {::mdef/name "penpot_jobs_total_ms" + ::mdef/help "Time from job creation to a terminal state (milliseconds)." + ::mdef/labels ["name" "queue" "outcome"] + ::mdef/type :histogram + ::mdef/buckets [10 50 100 500 1000 5000 10000 30000 60000 300000 600000 3600000]} + + :jobs-dispatcher-timing + {::mdef/name "penpot_jobs_dispatcher_ms" + ::mdef/help "Dispatcher batch processing time (milliseconds)." + ::mdef/labels ["stage" "outcome"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000]} + + :jobs-dispatcher-batch-size + {::mdef/name "penpot_jobs_dispatcher_batch_size" + ::mdef/help "Number of jobs in a dispatcher queue batch." + ::mdef/labels ["queue"] + ::mdef/type :histogram + ::mdef/buckets [1 5 10 25 50 100 250 500 1000]} + + :jobs-backlog + {::mdef/name "penpot_jobs_backlog" + ::mdef/help "Current number of jobs grouped by status." + ::mdef/labels ["status"] + ::mdef/type :gauge} + + :jobs-oldest-pending-age + {::mdef/name "penpot_jobs_oldest_pending_age_seconds" + ::mdef/help "Age in seconds of the oldest pending job." + ::mdef/type :gauge} + + :jobs-gc-rows + {::mdef/name "penpot_jobs_gc_rows_total" + ::mdef/help "Rows removed or resources touched by the jobs garbage collector." + ::mdef/labels ["kind" "action"] + ::mdef/type :counter} + + :jobs-gc-timing + {::mdef/name "penpot_jobs_gc_ms" + ::mdef/help "Time spent in each jobs garbage collection pass (milliseconds)." + ::mdef/labels ["kind"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]} + + :jobs-cron-total + {::mdef/name "penpot_jobs_cron_total" + ::mdef/help "Total number of cron scheduler events." + ::mdef/labels ["outcome" "reason"] + ::mdef/type :counter} + + :jobs-requests-total + {::mdef/name "penpot_jobs_requests_total" + ::mdef/help "Total number of ephemeral job request events." + ::mdef/labels ["outcome"] + ::mdef/type :counter} + + :jobs-request-timing + {::mdef/name "penpot_jobs_request_ms" + ::mdef/help "Time spent waiting for an ephemeral job reply (milliseconds)." + ::mdef/labels ["outcome"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]}}) (def system-config {::db/pool @@ -272,27 +398,12 @@ :app.storage.tmp/cleaner {::wrk/executor (ig/ref ::wrk/executor)} - ::sto.gc-deleted/handler - {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} - - ::sto.gc-touched/handler - {::db/pool (ig/ref ::db/pool) - ::mtx/metrics (ig/ref ::mtx/metrics)} - - ::sto.pending-gc/handler - {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} - ::http.client/client {::wrk/executor (ig/ref ::wrk/executor)} ::session/manager {::db/pool (ig/ref ::db/pool)} - ::session.tasks/gc - {::db/pool (ig/ref ::db/pool)} - ::http.awsns/routes {::setup/props (ig/ref ::setup/props) ::db/pool (ig/ref ::db/pool) @@ -341,6 +452,7 @@ ::oidc/routes {::http.client/client (ig/ref ::http.client/client) + ::jobs/defs (ig/ref ::jobs/defs) ::db/pool (ig/ref ::db/pool) ::setup/props (ig/ref ::setup/props) ::oidc/providers (ig/ref ::oidc/providers) @@ -413,6 +525,7 @@ :app.rpc/methods {::http.client/client (ig/ref ::http.client/client) + ::jobs/defs (ig/ref ::jobs/defs) ::db/pool (ig/ref ::db/pool) ::rds/pool (ig/ref ::rds/pool) :app.nitrate/client (ig/ref :app.nitrate/client) @@ -441,10 +554,12 @@ :app.nitrate/client {::http.client/client (ig/ref ::http.client/client) + ::jobs/defs (ig/ref ::jobs/defs) ::setup/shared-keys (ig/ref ::setup/shared-keys)} :app.rpc/management-methods {::http.client/client (ig/ref ::http.client/client) + ::jobs/defs (ig/ref ::jobs/defs) ::db/pool (ig/ref ::db/pool) ::rds/pool (ig/ref ::rds/pool) ::wrk/executor (ig/ref ::wrk/executor) @@ -466,31 +581,108 @@ ::setup/props (ig/ref ::setup/props) ::setup/shared-keys (ig/ref ::setup/shared-keys)} - ::wrk/registry - {::mtx/metrics (ig/ref ::mtx/metrics) - ::wrk/tasks - {:sendmail (ig/ref ::email/handler) - :objects-gc (ig/ref :app.tasks.objects-gc/handler) - :file-gc (ig/ref :app.tasks.file-gc/handler) - :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/handler) - :offload-file-data (ig/ref :app.tasks.offload-file-data/handler) - :tasks-gc (ig/ref :app.tasks.tasks-gc/handler) - :telemetry (ig/ref :app.tasks.telemetry/handler) - :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) - :storage-gc-touched (ig/ref ::sto.gc-touched/handler) - :storage-pending-gc (ig/ref ::sto.pending-gc/handler) - :session-gc (ig/ref ::session.tasks/gc) - :audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler) - :audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler) + ::jobs/defs + {:sendmail (ig/ref ::email/job-def) + :delete-object (ig/ref :app.tasks.delete-object/job-def) + :demo-purge (ig/ref :app.tasks.demo-purge/job-def) + :run-webhook (ig/ref ::webhooks/run-webhook-job-def) + :process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def) + :file-gc (ig/ref :app.tasks.file-gc/job-def) + :offload-file-data (ig/ref :app.tasks.offload-file-data/job-def) + :objects-gc (ig/ref :app.tasks.objects-gc/job-def) + :storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def) + :storage-gc-touched (ig/ref ::sto.gc-touched/job-def) + :storage-pending-gc (ig/ref ::sto.pending-gc/job-def) + :jobs-gc (ig/ref :app.jobs.gc/job-def) + :telemetry (ig/ref :app.tasks.telemetry/job-def) + :session-gc (ig/ref ::session/job-def) + :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) + :audit-log-archive (ig/ref :app.loggers.audit.archive-task/job-def) + :audit-log-gc (ig/ref :app.loggers.audit.gc-task/job-def)} - :delete-object - (ig/ref :app.tasks.delete-object/handler) - :demo-purge - (ig/ref :app.tasks.demo-purge/handler) - :process-webhook-event - (ig/ref ::webhooks/process-event-handler) - :run-webhook - (ig/ref ::webhooks/run-webhook-handler)}} + ::jobs/request-pool + {::rds/client (ig/ref ::rds/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.email/job-def + {::email/sendmail (ig/ref ::email/sendmail) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.delete-object/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.demo-purge/job-def + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.loggers.webhooks/run-webhook-job-def + {::db/pool (ig/ref ::db/pool) + ::http.client/client (ig/ref ::http.client/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.loggers.webhooks/process-webhook-event-job-def + {::db/pool (ig/ref ::db/pool) + ::http.client/client (ig/ref ::http.client/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.file-gc/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.offload-file-data/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.objects-gc/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.storage.gc-deleted/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.storage.gc-touched/job-def + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.storage.pending-gc/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.jobs.gc/job-def + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.telemetry/job-def + {::db/pool (ig/ref ::db/pool) + ::http.client/client (ig/ref ::http.client/client) + ::setup/props (ig/ref ::setup/props) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + ::session/job-def + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.tasks.file-gc-scheduler/job-def + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.loggers.audit.archive-task/job-def + {::db/pool (ig/ref ::db/pool) + ::http.client/client (ig/ref ::http.client/client) + ::setup/shared-keys (ig/ref ::setup/shared-keys) + ::mtx/metrics (ig/ref ::mtx/metrics)} + + :app.loggers.audit.gc-task/job-def + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} ::email/blacklist {} @@ -508,38 +700,6 @@ ::email/default-reply-to (cf/get :smtp-default-reply-to) ::email/default-from (cf/get :smtp-default-from)} - ::email/handler - {::email/sendmail (ig/ref ::email/sendmail)} - - :app.tasks.tasks-gc/handler - {::db/pool (ig/ref ::db/pool)} - - :app.tasks.objects-gc/handler - {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} - - :app.tasks.delete-object/handler - {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} - - :app.tasks.demo-purge/handler - {::db/pool (ig/ref ::db/pool)} - - :app.tasks.file-gc/handler - {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} - - :app.tasks.file-gc-scheduler/handler - {::db/pool (ig/ref ::db/pool)} - - :app.tasks.offload-file-data/handler - {::db/pool (ig/ref ::db/pool) - ::sto/storage (ig/ref ::sto/storage)} - - :app.tasks.telemetry/handler - {::db/pool (ig/ref ::db/pool) - ::http.client/client (ig/ref ::http.client/client) - ::setup/props (ig/ref ::setup/props)} ::srepl/urepl {:port (cf/get :urepl-port 6062) @@ -573,23 +733,6 @@ ::setup/clock {} - :app.loggers.audit.archive-task/handler - {::setup/shared-keys (ig/ref ::setup/shared-keys) - ::http.client/client (ig/ref ::http.client/client) - ::db/pool (ig/ref ::db/pool) - :app.nitrate/client (ig/ref :app.nitrate/client)} - - :app.loggers.audit.gc-task/handler - {::db/pool (ig/ref ::db/pool)} - - ::webhooks/process-event-handler - {::db/pool (ig/ref ::db/pool) - ::http.client/client (ig/ref ::http.client/client)} - - ::webhooks/run-webhook-handler - {::db/pool (ig/ref ::db/pool) - ::http.client/client (ig/ref ::http.client/client)} - :app.loggers.mattermost/reporter {::http.client/client (ig/ref ::http.client/client)} @@ -629,42 +772,43 @@ (def worker-config {::wrk/cron - {::wrk/registry (ig/ref ::wrk/registry) + {::jobs/defs (ig/ref ::jobs/defs) ::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics) ::wrk/entries [{:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :session-gc} + :job :session-gc} {:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :objects-gc} + :job :objects-gc} {:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :storage-gc-deleted} + :job :storage-gc-deleted} {:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :storage-gc-touched} + :job :storage-gc-touched} {:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :storage-pending-gc} + :job :storage-pending-gc} {:cron #penpot/cron "0 0 0 * * ?" ;; daily - :task :tasks-gc} + :job :jobs-gc} {:cron #penpot/cron "0 0 2 * * ?" ;; daily - :task :file-gc-scheduler} + :job :file-gc-scheduler} {:cron #penpot/cron "0 30 */3,23 * * ?" - :task :telemetry} + :job :telemetry} (when (or (contains? cf/flags :nexus) (contains? cf/flags :admin-console)) {:cron #penpot/cron "0 */5 * * * ?" ;; every 5m - :task :audit-log-archive}) + :job :audit-log-archive}) (when (or (contains? cf/flags :nexus) (contains? cf/flags :admin-console)) {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m - :task :audit-log-gc})]} + :job :audit-log-gc})]} ::wrk/dispatcher {::rds/client (ig/ref ::rds/client) @@ -672,23 +816,39 @@ ::db/pool (ig/ref ::db/pool) ::wrk/tenant (cf/get :tenant)} + ::jobs-metrics/sampler + {::db/pool (ig/ref ::db/pool) + ::mtx/metrics (ig/ref ::mtx/metrics)} + [::default ::wrk/runner] - {::wrk/parallelism (cf/get ::worker-default-parallelism 1) + {::wrk/parallelism (cf/get :worker-default-parallelism 1) ::wrk/queue :default ::wrk/tenant (cf/get :tenant) ::rds/client (ig/ref ::rds/client) - ::wrk/registry (ig/ref ::wrk/registry) + ::jobs/defs (ig/ref ::jobs/defs) ::mtx/metrics (ig/ref ::mtx/metrics) - ::db/pool (ig/ref ::db/pool)} + ::db/pool (ig/ref ::db/pool) + ::mbus/msgbus (ig/ref ::mbus/msgbus)} [::webhook ::wrk/runner] - {::wrk/parallelism (cf/get ::worker-webhook-parallelism 1) + {::wrk/parallelism (cf/get :worker-webhook-parallelism 1) ::wrk/queue :webhooks ::wrk/tenant (cf/get :tenant) ::rds/client (ig/ref ::rds/client) - ::wrk/registry (ig/ref ::wrk/registry) + ::jobs/defs (ig/ref ::jobs/defs) ::mtx/metrics (ig/ref ::mtx/metrics) - ::db/pool (ig/ref ::db/pool)}}) + ::db/pool (ig/ref ::db/pool) + ::mbus/msgbus (ig/ref ::mbus/msgbus)} + + [::cron ::wrk/runner] + {::wrk/parallelism (cf/get :worker-cron-parallelism 2) + ::wrk/queue :cron + ::wrk/tenant (cf/get :tenant) + ::rds/client (ig/ref ::rds/client) + ::jobs/defs (ig/ref ::jobs/defs) + ::mtx/metrics (ig/ref ::mtx/metrics) + ::db/pool (ig/ref ::db/pool) + ::mbus/msgbus (ig/ref ::mbus/msgbus)}}) (defn start diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index aff1e846f5..8960c625a2 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -7,6 +7,7 @@ (ns app.metrics (:refer-clojure :exclude [run!]) (:require + [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] [app.metrics.definition :as-alias mdef] @@ -159,6 +160,25 @@ (swap! warned-hints conj hint) (l/wrn :hint hint :cause cause)))) +(defn instance + "Resolve the metrics instance from a cfg; raises when it is absent. + + The shared resolver: a module that records metrics takes a cfg and + calls this, so no caller has to know where the instance lives or + capture it beforehand. That matters most inside an + `app.db/after-commit` callback, which closes over whatever it was + given and cannot read the cfg again later. + + Raises on purpose. A cfg without metrics is a wiring bug, and it must + surface where the metric is recorded rather than disappear: a caller + that resolved this at the top of a write and dropped the value is + precisely the pattern this replaces." + [cfg] + (or (::metrics cfg) + (ex/raise :type :assertion + :code :missing-metrics + :hint "missing ::mtx/metrics on the provided cfg"))) + (defn run! "Record a metric. diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index ff9b9a4c6f..1a4f039f59 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -507,6 +507,9 @@ {:name "0154-add-upload-session-chunk-table" :fn (mg/resource "app/migrations/sql/0154-add-upload-session-chunk-table.sql")} + {:name "0154-add-job-table" + :fn (mg/resource "app/migrations/sql/0154-add-job-table.sql")} + {:name "0155-normalize-storage-object-metadata" :fn (mg/resource "app/migrations/sql/0155-normalize-storage-object-metadata.sql")} diff --git a/backend/src/app/migrations/sql/0154-add-job-table.sql b/backend/src/app/migrations/sql/0154-add-job-table.sql new file mode 100644 index 0000000000..4c2c7c6bae --- /dev/null +++ b/backend/src/app/migrations/sql/0154-add-job-table.sql @@ -0,0 +1,150 @@ +-- Migration: Add unified `job` table. +-- +-- A single row per unit of work, replacing the generic `task` table as the +-- durable substrate for backend jobs. Columns combine the dispatch/lifecycle +-- fields of `task` with optional user-facing ledger fields (borrowed from the +-- planned export_job/import_job tables, which this migration supersedes). +-- +-- Job kinds are identified by `name` (the job-def registry key); user jobs are +-- marked by `profile_id IS NOT NULL` and expirable jobs by `expires_at IS NOT +-- NULL`. There is no `modified_at` trigger: the application code updates it on +-- every UPDATE (used for the unified lease/orphan detection). +-- +-- `params` carries the business payload (plain JSON) of the job. Progress is +-- not a column: it is an append-only `job_event` row, so a job keeps its whole +-- progress history instead of only the last value. +-- +-- `tenant` is the instance the job belongs to and `queue` is its bare queue +-- name. They are separate columns because several instances can share one +-- database and the tenant is what keeps them from claiming each other's work: +-- as an equality the filter is something an index can serve, where a +-- `:` prefix could only be matched with a LIKE over text. Every +-- query that acts on one instance's work (dispatch, orphan sweep, reschedule, +-- cron no-overlap check, submit dedupe) filters on `tenant` for that reason. +-- The prefix still exists, but only in the Redis key the queue's payloads +-- travel through, composed in one place by `app.worker/queue-key`. +-- +-- The legacy `task` table stays in place (dormant) on purpose until the +-- next version, so PRE (legacy) and HOURLY (jobs) can run in parallel on +-- the same database and the deploy can roll back. During that window +-- each table is cleaned by its own version: `task` rows by the parallel +-- legacy `tasks-gc`, `job` rows by the new `:jobs-gc`. + +CREATE TABLE job ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + tenant text NOT NULL, + queue text NOT NULL, + label text, + priority int NOT NULL DEFAULT 100, + scheduled_at timestamptz NOT NULL DEFAULT now(), + retry_num int NOT NULL DEFAULT 0, + max_retries int NOT NULL DEFAULT 3, + status text NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'scheduled', 'running', 'retry', + 'completed', 'failed', 'cancelled', + 'aborted')), + created_at timestamptz NOT NULL DEFAULT now(), + modified_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + completed_at timestamptz, + params jsonb NOT NULL DEFAULT '{}', + + -- optional ledger columns (user-facing jobs) + profile_id uuid NULL REFERENCES profile(id) ON DELETE NO ACTION DEFERRABLE, + error jsonb, + result jsonb, + resource_id uuid NULL REFERENCES storage_object(id) ON DELETE SET NULL DEFERRABLE, + expires_at timestamptz +); + +-- Dispatcher claim index: the worker selects due `new`/`retry` rows of +-- its own tenant, orders them by priority and scheduled time, and locks +-- the batch with SKIP LOCKED. The partial predicate keeps terminal rows +-- out and `status` needs no key column for that reason; `tenant` leads +-- because it is an equality the index can answer, and the leading +-- columns then carry the ORDER BY unchanged. `queue` does not lead: the +-- dispatcher claims every queue of its tenant and groups them when +-- pushing, so filtering on one queue would be wrong. +CREATE INDEX job__dispatcher__idx + ON job (tenant, priority DESC, scheduled_at) + WHERE status IN ('new', 'retry'); + +-- Dispatcher orphan sweep: the worker finds the `running` rows of its +-- own tenant whose modified_at is older than the lease and marks them +-- `aborted` (system-side terminal, never retried). +CREATE INDEX job__orphan__idx + ON job (tenant, modified_at) + WHERE status = 'running'; + +-- User-facing job ledger index: it supports filtering by profile and +-- ordering a user's jobs by newest first. No current production query +-- uses it yet; it is kept for the user-facing ledger path. +CREATE INDEX job__profile__idx + ON job (profile_id, created_at DESC) + WHERE profile_id IS NOT NULL; + +-- Cron no-overlap check and submit dedupe: both look for jobs of one +-- tenant with the same name and queue and label that have not started +-- yet, so one index serves the two queries. +CREATE INDEX job__name_label__idx + ON job (tenant, name, queue, label) + WHERE status IN ('new', 'scheduled', 'running', 'retry'); + +-- Storage GC reference check: it answers whether any job still points +-- to a candidate storage object. Rows without a resource are excluded. +CREATE INDEX job__resource__idx + ON job (resource_id) + WHERE resource_id IS NOT NULL; + +-- Dispatcher recovery: it finds the `scheduled` jobs of its own tenant +-- that were pushed to Redis but not claimed within the recovery window. +CREATE INDEX job__scheduled__idx + ON job (tenant, scheduled_at) + WHERE status = 'scheduled'; + +-- Jobs GC expiration: it scans jobs past expires_at while excluding +-- running and retry jobs from the expiration delete. +CREATE INDEX job__expires__idx + ON job (expires_at) + WHERE expires_at IS NOT NULL; + +-- Jobs GC retention: it finds old internal terminal jobs with no +-- profile, which are eligible for removal after the retention delay. +CREATE INDEX job__retention__idx + ON job (modified_at) + WHERE status IN ('completed', 'failed', 'cancelled', 'aborted') + AND profile_id IS NULL; + +-- Append-only job event log: `start`, `progress`, `retry` and `end` rows in +-- insertion order. This is the only source of truth for progress; there is no +-- Redis key and no mutable progress column. The foreign key cascades so +-- deleting a job removes its whole history, and it is DEFERRABLE like every +-- other new reference of the substrate. +CREATE TABLE job_event ( + id bigserial PRIMARY KEY, + job_id uuid NOT NULL REFERENCES job(id) ON DELETE CASCADE DEFERRABLE, + kind text NOT NULL + CHECK (kind IN ('start', 'progress', 'retry', 'end')), + payload jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Event history read: all events of a job, and the last progress report +-- (`WHERE job_id = ? AND kind = 'progress' ORDER BY created_at DESC LIMIT 1`). +CREATE INDEX job_event__job_kind_created_idx + ON job_event (job_id, kind, created_at DESC, id DESC); + +-- Deletion protection, like the domain tables (`team`, `profile`, the +-- file thumbnail tables). The only intended deleter of `job` rows is the +-- jobs GC, which also marks the referenced storage objects as touched in +-- the same transaction; a stray `DELETE FROM job` would drop the row +-- (and cascade `job_event`) without touching the object, orphaning it +-- forever. The GC and the submit dedupe disable the guard inside their +-- transaction with `SET LOCAL rules.deletion_protection TO off`, the +-- same escape hatch used by `app.tasks.objects-gc`. +CREATE OR REPLACE TRIGGER deletion_protection__tgr +BEFORE DELETE ON job FOR EACH STATEMENT + WHEN ((current_setting('rules.deletion_protection', true) IN ('on', '')) OR + (current_setting('rules.deletion_protection', true) IS NULL)) + EXECUTE PROCEDURE raise_deletion_protection(); diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index a759e63c57..15214ac449 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -17,7 +17,6 @@ [app.common.time :as ct] [app.common.types.organization :as cto :refer [schema:nitrate-sso]] - [app.common.uri :as u] [app.config :as cf] [app.http.client :as http] [app.http.session :as session] @@ -32,31 +31,17 @@ ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- join-path-segments - "Build a single relative path from Nitrate URI segments, normalizing slashes." - [segments] - (let [path (->> segments (map str) (str/join "/"))] - (->> (str/split path #"/") - (remove str/blank?) - (str/join "/")))) - -(defn- join-base-uri - "Join path segments to a base URI." - [base-uri & segments] - (u/join (u/ensure-path-slash base-uri) - (join-path-segments segments))) - (defn- generate-nitrate-uri "Joins relative path segments to the Nitrate backend URI. Segments must not start with `/`" [& segments] - (apply join-base-uri (cf/get :admin-console-uri) segments)) + (apply cf/join-uri (cf/get :admin-console-uri) segments)) (defn- generate-public-uri "Joins relative path segments to the public backend URI. Segments must not start with `/`" [& segments] - (apply join-base-uri (cf/get :public-uri) segments)) + (apply cf/get-public-uri segments)) (defn- request-builder [cfg method uri shared-key profile-id request-params] diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index 7a71d4ba66..c7ea49be53 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -78,6 +78,7 @@ (-hget [_ key field]) (-set [_ key val args]) (-del [_ key-or-keys]) + (-expire [_ key seconds]) (-ping [_])) (defprotocol IPubSubConnection @@ -234,6 +235,10 @@ (let [keys (into-array String keys)] (.del cmd ^String/1 keys))) + (-expire [_ key seconds] + (assert (string? key) "key expected to be string") + (.expire cmd ^String key ^long seconds)) + (-ping [_] (.ping cmd)) @@ -511,6 +516,11 @@ [cause] (instance? RedisCommandTimeoutException cause)) +(defn expire + [conn key seconds] + (assert (string? key) "key must be string instance") + (-expire conn key seconds)) + (defn exception? [cause] (instance? RedisException cause)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index a558315376..cf80790b2e 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -13,7 +13,6 @@ [app.common.schema :as sm] [app.common.spec :as us] [app.common.time :as ct] - [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -426,7 +425,8 @@ (defn- resolve-management-methods [cfg] (let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing) - mods (cond->> (list 'app.rpc.management.exporter) + mods (cond->> (list 'app.rpc.management.exporter + 'app.rpc.management.jobs) (contains? cf/flags :admin-console) (cons 'app.rpc.management.nitrate))] @@ -482,42 +482,40 @@ (defmethod ig/init-key ::routes [_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}] + ["/api" + ["/management" + ["/methods/:method-name" + {:middleware [[mw/shared-key-auth shared-keys] + [session/authz cfg]] + :handler (make-rpc-handler management-methods)}] - (let [public-uri (cf/get :public-uri)] - ["/api" - ["/management" - ["/methods/:method-name" - {:middleware [[mw/shared-key-auth shared-keys] - [session/authz cfg]] - :handler (make-rpc-handler management-methods)}] + (doc/routes :methods management-methods + :label "management" + :base-uri (cf/get-public-uri "api/management") + :description "MANAGEMENT API")] - (doc/routes :methods management-methods - :label "management" - :base-uri (u/join public-uri "/api/management") - :description "MANAGEMENT API")] + ["/main" + ["/methods/:method-name" + {:middleware [[mw/cors] + [sec/client-header-check] + [session/authz cfg] + [actoken/authz cfg]] + :handler (make-rpc-handler methods)}] - ["/main" - ["/methods/:method-name" - {:middleware [[mw/cors] - [sec/client-header-check] - [session/authz cfg] - [actoken/authz cfg]] - :handler (make-rpc-handler methods)}] + (doc/routes :methods methods + :label "main" + :base-uri (cf/get-public-uri "api/main") + :description "MAIN API")] - (doc/routes :methods methods - :label "main" - :base-uri (u/join public-uri "/api/main") - :description "MAIN API")] + ;; BACKWARD COMPATIBILITY + ["/_doc" {:handler (redirect (cf/get-public-uri "api/main/doc"))}] + ["/doc" {:handler (redirect (cf/get-public-uri "api/main/doc"))}] + ["/openapi" {:handler (redirect (cf/get-public-uri "api/main/doc/openapi"))}] + ["/openapi.join" {:handler (redirect (cf/get-public-uri "api/main/doc/openapi.json"))}] - ;; BACKWARD COMPATIBILITY - ["/_doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}] - ["/doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}] - ["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}] - ["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}] - - ["/rpc/command/:method-name" - {:middleware [[mw/cors] - [sec/client-header-check] - [session/authz cfg] - [actoken/authz cfg]] - :handler (make-rpc-handler methods)}]])) + ["/rpc/command/:method-name" + {:middleware [[mw/cors] + [sec/client-header-check] + [session/authz cfg] + [actoken/authz cfg]] + :handler (make-rpc-handler methods)}]]) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index a838eff3be..549d24a3ba 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -202,11 +202,11 @@ (update-password conn))] (when profile (login-lockout/clear-attempts! cfg (:id profile)) - (eml/send! {::eml/conn conn - ::eml/factory eml/password-changed - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile)}))) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/password-changed + :public-uri (cf/get :public-uri) + :to (:email profile) + :name (:fullname profile)}))) nil)) @@ -467,7 +467,7 @@ (defn send-email-verification! ([cfg profile] (send-email-verification! cfg profile nil)) - ([{:keys [::db/conn] :as cfg} profile invitation-token] + ([cfg profile invitation-token] (let [vclaims (cond-> {:iss :verify-email :exp (ct/in-future "72h") :profile-id (:id profile) @@ -485,13 +485,13 @@ {:iss :profile-identity :profile-id (:id profile) :exp (ct/in-future {:days 30})})] - (eml/send! {::eml/conn conn - ::eml/factory eml/register - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile) - :token vtoken - :extra-data ptoken})))) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/register + :public-uri (cf/get :public-uri) + :to (:email profile) + :name (:fullname profile) + :token vtoken + :extra-data ptoken})))) (defn register-profile [{:keys [::db/conn] :as cfg} {:keys [token] :as params}] @@ -649,18 +649,18 @@ :profile-id id})] (assoc profile :token token))) - (send-email-notification [conn profile] + (send-email-notification [profile] (let [ptoken (tokens/generate cfg {:iss :profile-identity :profile-id (:id profile) :exp (ct/in-future {:days 30})})] - (eml/send! {::eml/conn conn - ::eml/factory eml/password-recovery - :public-uri (cf/get :public-uri) - :to (:email profile) - :token (:token profile) - :name (:fullname profile) - :extra-data ptoken}) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/password-recovery + :public-uri (cf/get :public-uri) + :to (:email profile) + :token (:token profile) + :name (:fullname profile) + :extra-data ptoken}) nil))] (let [profile (->> (profile/clean-email email) @@ -698,7 +698,7 @@ {:id (:id profile)}) (->> profile (create-recovery-token) - (send-email-notification conn))))))) + (send-email-notification))))))) (def schema:request-profile-recovery [:map {:title "request-profile-recovery"} diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 9de796a72e..459ebbeaa0 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -83,7 +83,7 @@ (not= :none (-> props :notifications :email-comments))) (defn send-comment-emails! - [conn profile comment thread file] + [{:keys [::db/conn] :as cfg} profile comment thread file] (let [team-users (get-team-users conn (:team-id file)) comment-reference (format-comment-ref thread file) comment-content (format-comment comment) @@ -113,8 +113,9 @@ (doseq [mention comment-mentions] (let [{:keys [fullname email props]} (get team-users mention)] (when (mention-email? props) - (eml/send! - {::eml/conn conn + (eml/send + cfg + {::eml/reuse-conn true ::eml/factory eml/comment-mention :public-uri (cf/get :public-uri) :to email @@ -128,8 +129,9 @@ (doseq [mention thread-mentions] (let [{:keys [fullname email props]} (get team-users mention)] (when (mention-email? props) - (eml/send! - {::eml/conn conn + (eml/send + cfg + {::eml/reuse-conn true ::eml/factory eml/comment-thread :public-uri (cf/get :public-uri) :to email @@ -143,8 +145,9 @@ (doseq [user-id notificate-users-ids] (let [{:keys [id fullname email props]} (get team-users user-id)] (when (notification-email? id (:owner-id thread) props) - (eml/send! - {::eml/conn conn + (eml/send + cfg + {::eml/reuse-conn true ::eml/factory eml/comment-notification :public-uri (cf/get :public-uri) :to email @@ -538,7 +541,7 @@ (update-thread-seqn conn file-id seqn) ;; Send mentions emails - (send-comment-emails! conn profile comment thread file) + (send-comment-emails! cfg profile comment thread file) (-> thread (add-owner profile) @@ -648,7 +651,7 @@ ;; Update the current profile status in relation to the current thread (upsert-comment-thread-status! conn profile-id thread-id) - (send-comment-emails! conn profile comment thread file) + (send-comment-emails! cfg profile comment thread file) (vary-meta comment assoc ::audit/props comment)))) diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 1e9f81ea63..5451e2059e 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -14,12 +14,12 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.jobs :as jobs] [app.loggers.audit :as audit] [app.rpc :as-alias rpc] [app.rpc.commands.auth :as auth] [app.rpc.doc :as-alias doc] [app.util.services :as sv] - [app.worker :as wrk] [buddy.core.codecs :as bc] [buddy.core.nonce :as bn])) @@ -101,10 +101,10 @@ (->> (auth/create-profile cfg params) (auth/create-profile-rels cfg))))] - (wrk/submit! (-> cfg - (assoc ::wrk/task :demo-purge) - (assoc ::wrk/delay deletion-delay) - (assoc ::wrk/params {:profile-id (:id profile)}))) + (jobs/submit cfg + {::jobs/name :demo-purge + ::jobs/delay deletion-delay + ::jobs/params {:profile-id (:id profile)}}) (with-meta {:email email :password password} diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 346697abbc..7ff4076da7 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -32,20 +32,22 @@ (sv/defmethod ::send-user-feedback {::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id] [:send-user-feedback/global]] + ::db/transaction true ::doc/added "1.18" ::sm/params schema:send-user-feedback} - [{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}] + [cfg {:keys [::rpc/profile-id] :as params}] (when-not (contains? cf/flags :user-feedback) (ex/raise :type :restriction :code :feedback-disabled :hint "feedback not enabled")) - (let [profile (profile/get-profile pool profile-id)] - (send-user-feedback! pool profile params) + (let [pool (::db/pool cfg) + profile (profile/get-profile pool profile-id)] + (send-user-feedback! cfg profile params) nil)) (defn- send-user-feedback! - [pool profile params] + [cfg profile params] (let [destination (or (cf/get :user-feedback-destination) ;; LEGACY @@ -55,16 +57,16 @@ (d/without-nils {"error-report.txt" (:error-report params)})] - (eml/send! {::eml/conn pool - ::eml/factory eml/user-feedback - :to destination - :reply-to (:email profile) - :email (:email profile) - :attachments attachments + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/user-feedback + :to destination + :reply-to (:email profile) + :email (:email profile) + :attachments attachments - :feedback-subject (:subject params) - :feedback-type (:type params "not-specified") - :feedback-content (:content params) - :feedback-error-href (:error-href params) - :profile profile}) + :feedback-subject (:subject params) + :feedback-type (:type params "not-specified") + :feedback-content (:content params) + :feedback-error-href (:error-href params) + :profile profile}) nil)) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index e10dcd4b9e..63e116391b 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -30,6 +30,7 @@ [app.features.fdata :as feat.fdata] [app.features.logical-deletion :as ldel] [app.http.sse :as sse] + [app.jobs :as jobs] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.msgbus :as mbus] @@ -45,7 +46,6 @@ [app.util.events :as events] [app.util.pointer-map :as pmap] [app.util.services :as sv] - [app.worker :as wrk] [cuerdas.core :as str])) ;; --- FEATURES @@ -1055,7 +1055,7 @@ ;; --- MUTATION COMMAND: delete-file (defn- mark-file-deleted - [conn team file-id] + [{:keys [::db/conn] :as cfg} team file-id] (let [delay (ldel/get-deletion-delay team) file (db/update! conn :file {:deleted-at (ct/in-future delay)} @@ -1067,11 +1067,11 @@ (db/delete! conn :file-library-rel {:library-file-id file-id}) - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :file - :deleted-at (:deleted-at file) - :id file-id}}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :file + :deleted-at (:deleted-at file) + :id file-id}}) file)) (def ^:private @@ -1085,7 +1085,7 @@ (let [team (teams/get-team conn :profile-id profile-id :file-id id) - file (mark-file-deleted conn team id) + file (mark-file-deleted cfg team id) msgbus (::mbus/msgbus cfg)] (mbus/pub! msgbus @@ -1265,7 +1265,7 @@ [:ids [::sm/set ::sm/uuid]]]) (defn- permanently-delete-team-files - [{:keys [::db/conn]} {:keys [::rpc/request-at team-id ids]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/request-at team-id ids]}] (let [ids (into #{} d/xf:map-id (db/exec! conn [sql:get-delete-team-files-candidates team-id @@ -1277,11 +1277,11 @@ {:deleted-at request-at} {:id id} {::db/return-keys false}) - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :file - :deleted-at request-at - :id id}}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :file + :deleted-at request-at + :id id}}) (conj acc id)) #{} ids))) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index cb3e93285f..032266a8d5 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -171,7 +171,7 @@ {::doc/added "1.20" ::sm/params schema:remove-file-snapshot ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}] (let [snapshot (fsnap/get-minimal-snapshot conn id)] (files/check-edition-permissions! conn profile-id (:file-id snapshot)) @@ -194,7 +194,7 @@ :profile-id profile-id :file-id (:file-id snapshot)) delay (ldel/get-deletion-delay team)] - (fsnap/delete! conn (assoc snapshot :deleted-at (ct/in-future delay)))))) + (fsnap/delete! cfg (assoc snapshot :deleted-at (ct/in-future delay)))))) ;;; Lock/unlock version endpoints diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 686884aeed..f388d0dfc0 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -20,6 +20,7 @@ [app.db.sql :as-alias sql] [app.email :as eml] [app.http.session :as session] + [app.jobs :as jobs] [app.loggers.audit :as audit] [app.main :as-alias main] [app.media :as media] @@ -33,7 +34,6 @@ [app.storage :as sto] [app.tokens :as tokens] [app.util.services :as sv] - [app.worker :as wrk] [cuerdas.core :as str])) (declare check-profile-existence!) @@ -225,11 +225,11 @@ (update-profile-password! cfg (assoc profile :password password)) - (eml/send! {::eml/conn (::db/conn cfg) - ::eml/factory eml/password-changed - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile)}) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/password-changed + :public-uri (cf/get :public-uri) + :to (:email profile) + :name (:fullname profile)}) (->> (rph/get-request params) (session/get-session) @@ -460,14 +460,14 @@ :email (:email profile) :hint "looks like the email has spam complaint reports")) - (eml/send! {::eml/conn conn - ::eml/factory eml/change-email - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile) - :pending-email email - :token token - :extra-data ptoken}) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/change-email + :public-uri (cf/get :public-uri) + :to (:email profile) + :name (:fullname profile) + :pending-email email + :token token + :extra-data ptoken}) nil)) ;; --- MUTATION: Update Profile Props @@ -570,11 +570,11 @@ {:profile-id profile-id})) ;; Schedule cascade deletion to a worker - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :profile - :deleted-at deleted-at - :id profile-id}}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :profile + :deleted-at deleted-at + :id profile-id}}) ;; Invalidate all sessions for this profile to ensure immediate ;; access revocation across all devices diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index e69c704270..cf902d4f50 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -15,6 +15,7 @@ [app.db :as db] [app.db.sql :as-alias sql] [app.features.logical-deletion :as ldel] + [app.jobs :as jobs] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as webhooks] [app.rpc :as-alias rpc] @@ -23,8 +24,7 @@ [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] - [app.util.services :as sv] - [app.worker :as wrk])) + [app.util.services :as sv])) ;; --- Check Project Permissions @@ -272,7 +272,7 @@ ;; --- MUTATION: Delete Project (defn- delete-project - [conn team project-id] + [{:keys [::db/conn] :as cfg} team project-id] (let [delay (ldel/get-deletion-delay team) project (db/update! conn :project {:deleted-at (ct/in-future delay)} @@ -284,11 +284,11 @@ :code :non-deletable-project :hint "impossible to delete default project")) - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :project - :deleted-at (:deleted-at project) - :id project-id}}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :project + :deleted-at (:deleted-at project) + :id project-id}}) project)) @@ -302,12 +302,12 @@ ::sm/params schema:delete-project ::webhooks/event? true ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}] (check-edition-permissions! conn profile-id id) (let [team (teams/get-team conn :profile-id profile-id :project-id id) - project (delete-project conn team id)] + project (delete-project cfg team id)] (rph/with-meta (rph/wrap) {::audit/props {:team-id (:team-id project) :name (:name project) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 405933a463..c2ec1fa720 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -20,6 +20,7 @@ [app.db.sql :as sql] [app.email :as eml] [app.features.logical-deletion :as ldel] + [app.jobs :as jobs] [app.loggers.audit :as audit] [app.main :as-alias main] [app.media.validation :as media.v] @@ -33,7 +34,6 @@ [app.setup :as-alias setup] [app.storage :as sto] [app.util.services :as sv] - [app.worker :as wrk] [clojure.set :as set])) ;; --- Helpers & Specs @@ -848,11 +848,11 @@ (when (contains? cf/flags :admin-console) (nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id})) - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :team - :deleted-at (:deleted-at team) - :id team-id}}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :team + :deleted-at (:deleted-at team) + :id team-id}}) team))) (def ^:private schema:delete-team diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index fe8abd5b24..6277506bda 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -288,24 +288,24 @@ (not recent?)) (if organization (when (contains? cf/flags :admin-console) - (eml/send! {::eml/conn conn - ::eml/factory eml/invite-to-organization - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :user-name (:fullname member) - :organization organization - :token itoken - :extra-data ptoken})) - (eml/send! {::eml/conn conn - ::eml/factory eml/invite-to-team - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :team (:name team) - :organization (:organization team) - :token itoken - :extra-data ptoken}))) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/invite-to-organization + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :user-name (:fullname member) + :organization organization + :token itoken + :extra-data ptoken})) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/invite-to-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :organization (:organization team) + :token itoken + :extra-data ptoken}))) itoken))))) @@ -350,13 +350,13 @@ (db/delete! conn :team-invitation {:team-id team-id :email-to (:email member)}) - (eml/send! {::eml/conn conn - ::eml/factory eml/join-team - :public-uri (cf/get :public-uri) - :to (:email member) - :invited-by (:fullname profile) - :team (:name team) - :team-id (:id team)}))) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/join-team + :public-uri (cf/get :public-uri) + :to (:email member) + :invited-by (:fullname profile) + :team (:name team) + :team-id (:id team)}))) (def ^:private sql:valid-access-request-profiles "SELECT p.id, p.email, p.is_blocked @@ -814,17 +814,17 @@ :else eml/request-team-access)] - (eml/send! {::eml/conn conn - ::eml/factory factory - :public-uri (cf/get :public-uri) - :to (:email team-owner) - :requested-by (:fullname requester) - :requested-by-email (:email requester) - :team-name (:name team) - :team-id team-id - :file-name (:name file) - :file-id file-id - :page-id (:page-id file)}) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory factory + :public-uri (cf/get :public-uri) + :to (:email team-owner) + :requested-by (:fullname requester) + :requested-by-email (:email requester) + :team-name (:name team) + :team-id team-id + :file-name (:name file) + :file-id file-id + :page-id (:page-id file)}) (with-meta {:request request} {::audit/props {:request 1}})))) diff --git a/backend/src/app/rpc/management/jobs.clj b/backend/src/app/rpc/management/jobs.clj new file mode 100644 index 0000000000..ab1561e704 --- /dev/null +++ b/backend/src/app/rpc/management/jobs.clj @@ -0,0 +1,140 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.management.jobs + "Generic job management API for external workers (media and future + subsystems). External workers never touch the database: this API is + the ledger. The methods are family-agnostic (they operate on any row + of the `job` table) and use the plain JSON schemas of `app.jobs`. + + Auth: every method declares ::rpc/auth false because these endpoints + do not require a profile. Actual authentication is enforced by the + management route resolver (resolve-management-methods in app.rpc), + which mandates a valid shared-key before dispatching to these methods. + If the route resolver changes, these methods must be updated accordingly." + (:require + [app.common.schema :as sm] + [app.common.time :as ct] + [app.jobs :as jobs] + [app.rpc :as-alias rpc] + [app.rpc.doc :as doc] + [app.util.services :as sv])) + +;; ---- RPC METHOD: CLAIM-JOB + +(def ^:private schema:claim-job-params + [:map {:title "claim-job-params"} + [:job-id ::sm/uuid] + [:scheduled-at ::ct/inst]]) + +(def ^:private schema:claim-job-result + [:map {:title "claim-job-result"} + [:action [:enum :run :skip]] + [:name {:optional true} ::sm/text] + ;; Opaque per-job params blob; intentionally untyped. + [:params {:optional true} :any]]) + +(sv/defmethod ::claim-job + {::doc/added "2.20" + ::sm/params schema:claim-job-params + ::sm/result schema:claim-job-result + ::rpc/auth false} ;; shared-key enforced by route resolver + [cfg {:keys [job-id scheduled-at]}] + (let [row (jobs/get-job cfg job-id)] + ;; Race condition: if the job is claimed between get-job and claim! by + ;; another worker, claim! returns 0 and we return :skip. The conditional + ;; claim (UPDATE ... WHERE status IN ('new','scheduled','retry') AND + ;; scheduled_at=?) handles this correctly: only a pending row with an + ;; exact scheduled-at match is claimed, a stale payload is skipped. + (if (and row (pos? (jobs/claim cfg job-id scheduled-at))) + {:action :run + :name (:name row) + :params (:params row)} + {:action :skip}))) + +;; ---- RPC METHOD: REPORT-JOB-PROGRESS + +(def ^:private schema:report-job-progress-params + [:map {:title "report-job-progress-params"} + ;; The progress schema is the same one the jobs substrate validates: + ;; `current` is mandatory, `total` and `stage` are optional, and no + ;; other key is accepted. + [:job-id ::sm/uuid] + [:progress jobs/schema:progress]]) + +(def ^:private schema:report-job-progress-result + [:map {:title "report-job-progress-result"} + [:action [:enum :run :skip]]]) + +(sv/defmethod ::report-job-progress + {::doc/added "2.20" + ::sm/params schema:report-job-progress-params + ::sm/result schema:report-job-progress-result + ::rpc/auth false} ;; shared-key enforced by route resolver + [cfg {:keys [job-id progress]}] + ;; A lost race (row already terminal) reports :skip so the worker + ;; stops retrying a report that can never land, mirroring claim-job. + ;; The forced route propagates database errors: the external worker + ;; must be able to retry a report it was not sure about. + (if (pos? (jobs/heartbeat cfg + :job-id job-id + :progress progress + ::jobs/force? true)) + {:action :run} + {:action :skip})) + +;; ---- RPC METHOD: COMPLETE-JOB + +(def ^:private schema:complete-job-params + [:map {:title "complete-job-params"} + [:job-id ::sm/uuid] + ;; Per-job result: any JSON-encodable value, nil when the job returns + ;; nothing. Nested maps and vectors are allowed; unserializable + ;; values are dropped to nil by jobs/encode-result with a warning. + [:result [:maybe :any]] + ;; Storage object the worker produced for this job. A resource is only + ;; set, never replaced: a job that already has one is rejected. + [:resource-id {:optional true} ::sm/uuid]]) + +(def ^:private schema:complete-job-result + [:map {:title "complete-job-result"} + [:action [:enum :run :skip]]]) + +(sv/defmethod ::complete-job + {::doc/added "2.20" + ::sm/params schema:complete-job-params + ::sm/result schema:complete-job-result + ::rpc/auth false} ;; shared-key enforced by route resolver + [cfg {:keys [job-id result resource-id]}] + (if (pos? (jobs/complete cfg + :job-id job-id + :result result + :resource-id resource-id)) + {:action :run} + {:action :skip})) + +;; ---- RPC METHOD: FAIL-JOB + +(def ^:private schema:fail-job-params + [:map {:title "fail-job-params"} + [:job-id ::sm/uuid] + ;; Rich error report: type/code/hint are required, the map stays + ;; open to worker-defined details (see jobs/fail). + [:error jobs/schema:job-error]]) + +(def ^:private schema:fail-job-result + [:map {:title "fail-job-result"} + [:action [:enum :run :skip]]]) + +(sv/defmethod ::fail-job + {::doc/added "2.20" + ::sm/params schema:fail-job-params + ::sm/result schema:fail-job-result + ::rpc/auth false} ;; shared-key enforced by route resolver + [cfg {:keys [job-id error]}] + (if (pos? (jobs/fail cfg job-id error)) + {:action :run} + {:action :skip})) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 23a11f8151..751388c27f 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -25,6 +25,7 @@ [app.email :as eml] [app.http :as-alias http] [app.http.session :as session] + [app.jobs :as jobs] [app.loggers.audit :as audit] [app.media.validation :as media.v] [app.nitrate :as nitrate] @@ -42,7 +43,6 @@ [app.storage :as sto] [app.util.services :as sv] [app.util.ssrf :as ssrf] - [app.worker :as wrk] [cuerdas.core :as str])) @@ -381,11 +381,11 @@ RETURNING id, deleted_at;") deleted-at (db/create-array conn "uuid" team-ids)])] (doseq [{:keys [id deleted-at]} updated] - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :team - :deleted-at deleted-at - :id id}})))) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :team + :deleted-at deleted-at + :id id}})))) nil) (defn manage-deleted-organization-teams @@ -798,15 +798,15 @@ RETURNING id, deleted_at;") user-name (if (nil? user-name) (:fullname (profile/get-profile cfg profile-id)) (str/trim user-name))] - (db/tx-run! cfg (fn [{:keys [::db/conn]}] - (eml/send! {::eml/conn conn - ::eml/factory eml/renewal-notice - :public-uri (cf/get :public-uri) - :to user-email - :user-name user-name - :renewal-date renewal-date - :estimated-amount amount-str - :organizations organizations})))) + (db/tx-run! cfg (fn [tx-cfg] + (eml/send tx-cfg {::eml/reuse-conn true + ::eml/factory eml/renewal-notice + :public-uri (cf/get :public-uri) + :to user-email + :user-name user-name + :renewal-date renewal-date + :estimated-amount amount-str + :organizations organizations})))) nil) ;; API: exists-organization-team-invitations-for-non-members / diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj index 5a9379e358..926ef68c29 100644 --- a/backend/src/app/rpc/nitrate/emails_helper.clj +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -52,14 +52,14 @@ (defn- send-organization-setup-sso-email! "Send the organization SSO setup email to a single recipient, when allowed." - [conn organization-name {:keys [email profile]}] + [{:keys [::db/conn] :as cfg} organization-name {:keys [email profile]}] (when (or (nil? profile) (eml/allow-send-emails? conn profile)) - (eml/send! {::eml/conn conn - ::eml/factory eml/organization-setup-sso - :public-uri (cf/get :public-uri) - :to email - :organization-name organization-name}))) + (eml/send cfg {::eml/reuse-conn true + ::eml/factory eml/organization-setup-sso + :public-uri (cf/get :public-uri) + :to email + :organization-name organization-name}))) (defn- get-organization-sso-notify-recipients "Unique organization members and pending organization/team invitees for SSO activation emails." @@ -89,9 +89,9 @@ [cfg organization-id] (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})] (db/tx-run! cfg - (fn [{:keys [::db/conn]}] + (fn [{:keys [::db/conn] :as cfg}] (doseq [recipient (get-organization-sso-notify-recipients conn cfg organization-id organization-summary)] - (send-organization-setup-sso-email! conn (:name organization-summary) recipient)))))) + (send-organization-setup-sso-email! cfg (:name organization-summary) recipient)))))) (defn send-organization-setup-sso-emails-for-team! "Notify team members who are not in `organization-member-ids-before` and pending team invitees." @@ -99,6 +99,6 @@ (when (organization-sso-active? cfg organization-id) (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})] (db/tx-run! cfg - (fn [{:keys [::db/conn]}] + (fn [{:keys [::db/conn] :as cfg}] (doseq [recipient (get-team-sso-notify-recipients conn team-id organization-member-ids-before)] - (send-organization-setup-sso-email! conn (:name organization-summary) recipient))))))) + (send-organization-setup-sso-email! cfg (:name organization-summary) recipient))))))) diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 1239ce6f71..2cbe336a8d 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -13,7 +13,7 @@ [app.common.time :as ct] [app.config :as cf] [app.db :as db] - [app.worker :as wrk] + [app.jobs :as jobs] [cuerdas.core :as str])) (defmulti check-quote ::id) @@ -73,39 +73,39 @@ (run! (partial check cfg) others))))))) (defn- send-notification! - [{:keys [::db/conn] :as params}] + [cfg] (l/warn :hint "max quote reached" - :target (::target params) - :profile-id (some-> params ::profile-id str) - :team-id (some-> params ::team-id str) - :project-id (some-> params ::project-id str) - :file-id (some-> params ::file-id str) - :quote (::quote params) - :total (::total params) - :incr (::inc params 1)) + :target (::target cfg) + :profile-id (some-> cfg ::profile-id str) + :team-id (some-> cfg ::team-id str) + :project-id (some-> cfg ::project-id str) + :file-id (some-> cfg ::file-id str) + :quote (::quote cfg) + :total (::total cfg) + :incr (::inc cfg 1)) (when-let [admins (seq (cf/get :admins))] - (let [subject (str/istr "[quotes:notification]: max quote reached ~(::target params)") - content (str/istr "- Param: profile-id '~(::profile-id params)}'\n" - "- Param: team-id '~(::team-id params)'\n" - "- Param: project-id '~(::project-id params)'\n" - "- Param: file-id '~(::file-id params)'\n" - "- Quote ID: '~(::target params)'\n" - "- Max: ~(::quote params)\n" - "- Total: ~(::total params) (INCR ~(::incr params 1))\n")] - (wrk/submit! {::db/conn conn - ::wrk/task :sendmail - ::wrk/delay (ct/duration "30s") - ::wrk/max-retries 4 - ::wrk/priority 200 - ::wrk/dedupe true - ::wrk/label "quotes-notification" - ::wrk/params {:to (vec admins) - :subject subject - :body content}})))) + (let [subject (str/istr "[quotes:notification]: max quote reached ~(::target cfg)") + content (str/istr "- Param: profile-id '~(::profile-id cfg)}'\n" + "- Param: team-id '~(::team-id cfg)'\n" + "- Param: project-id '~(::project-id cfg)'\n" + "- Param: file-id '~(::file-id cfg)'\n" + "- Quote ID: '~(::target cfg)'\n" + "- Max: ~(::quote cfg)\n" + "- Total: ~(::total cfg) (INCR ~(::incr cfg 1))\n")] + (jobs/submit cfg + {::jobs/name :sendmail + ::jobs/delay (ct/duration "30s") + ::jobs/max-retries 4 + ::jobs/priority 200 + ::jobs/dedupe true + ::jobs/label "quotes-notification" + ::jobs/params {:to (vec admins) + :subject subject + :body content}})))) (defn- generic-check! - [{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}] + [{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as cfg}] (let [quote (->> (db/exec! conn quote-sql) (map :quote) (reduce max (- Integer/MAX_VALUE))) @@ -114,7 +114,7 @@ (when (> (+ total incr) quote) (if (contains? cf/flags :soft-quotes) - (send-notification! (assoc params ::quote quote ::total total)) + (send-notification! (assoc cfg ::quote quote ::total total)) (ex/raise :type :restriction :code :max-quote-reached :target target @@ -170,9 +170,9 @@ WHERE profile_id = ?") (defmethod check-quote ::teams-per-profile - [{:keys [::profile-id ::target] :as quote}] - (assert (valid-teams-per-profile-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::target] :as cfg}] + (assert (valid-teams-per-profile-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-teams-per-profile profile-id]) @@ -194,10 +194,10 @@ WHERE profile_id = ?") (defmethod check-quote ::access-tokens-per-profile - [{:keys [::profile-id ::target] :as quote}] - (assert (valid-access-tokens-per-profile-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::target] :as cfg}] + (assert (valid-access-tokens-per-profile-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-access-tokens-per-profile profile-id]) @@ -222,10 +222,10 @@ AND p.deleted_at IS NULL") (defmethod check-quote ::projects-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-projects-per-team-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-projects-per-team-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-projects-per-team team-id]) @@ -249,10 +249,10 @@ WHERE v.team_id = ?") (defmethod check-quote ::font-variants-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-font-variant-per-team-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-font-variant-per-team-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-font-variants-per-team team-id]) @@ -277,10 +277,10 @@ WHERE team_id = ?") (defmethod check-quote ::invitations-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-invitations-per-team-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-invitations-per-team-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-invitations-per-team team-id]) @@ -311,10 +311,10 @@ ;; effective members plus ongoing valid invitations. (defmethod check-quote ::profiles-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-profiles-per-team-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-profiles-per-team-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-profiles-per-team team-id team-id]) @@ -340,10 +340,10 @@ AND f.deleted_at IS NULL") (defmethod check-quote ::files-per-project - [{:keys [::profile-id ::project-id ::team-id ::target] :as quote}] - (assert (valid-files-per-project-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::project-id ::team-id ::target] :as cfg}] + (assert (valid-files-per-project-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id]) (assoc ::count-sql [sql:get-files-per-project project-id]) @@ -368,10 +368,10 @@ WHERE ct.file_id = ?") (defmethod check-quote ::comment-threads-per-file - [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] - (assert (valid-comment-threads-per-file-quote? quote) "invalid quote parameters") + [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as cfg}] + (assert (valid-comment-threads-per-file-quote? cfg) "invalid quote parameters") - (-> quote + (-> cfg (assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id profile-id team-id profile-id profile-id]) @@ -398,9 +398,9 @@ WHERE ct.file_id = ?") (defmethod check-quote ::comments-per-file - [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] - (assert (valid-comments-per-file-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as cfg}] + (assert (valid-comments-per-file-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id profile-id team-id profile-id profile-id]) @@ -430,9 +430,9 @@ AND fc.data IS NOT NULL") (defmethod check-quote ::snapshots-per-file - [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}] - (assert (valid-snapshots-per-file-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as cfg}] + (assert (valid-snapshots-per-file-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-snapshots-per-file Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id profile-id team-id profile-id profile-id]) @@ -463,9 +463,9 @@ AND fc.data IS NOT NULL") (defmethod check-quote ::snapshots-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-snapshots-per-team-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-snapshots-per-team-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-snapshots-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-snapshots-per-team team-id]) @@ -489,9 +489,9 @@ WHERE tar.team_id = ?") (defmethod check-quote ::team-access-requests-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-team-access-requests-per-team-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-team-access-requests-per-team-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-team-access-requests-per-team Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) (assoc ::count-sql [sql:get-team-access-requests-per-team team-id]) @@ -514,9 +514,9 @@ WHERE tar.requester_id = ?") (defmethod check-quote ::team-access-requests-per-requester - [{:keys [::profile-id ::target] :as quote}] - (assert (valid-team-access-requests-per-requester-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::target] :as cfg}] + (assert (valid-team-access-requests-per-requester-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-team-access-requests-per-requester Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id]) @@ -539,9 +539,9 @@ AND deleted_at IS NULL") (defmethod check-quote ::upload-sessions-per-profile - [{:keys [::profile-id ::target] :as quote}] - (assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::target] :as cfg}] + (assert (valid-upload-sessions-per-profile-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE)) (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) @@ -607,9 +607,9 @@ WHERE so.deleted_at IS NULL") (defmethod check-quote ::media-storage-bytes-per-team - [{:keys [::profile-id ::team-id ::target] :as quote}] - (assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters") - (-> quote + [{:keys [::profile-id ::team-id ::target] :as cfg}] + (assert (valid-media-storage-bytes-per-team-quote? cfg) "invalid quote parameters") + (-> cfg (assoc ::default (cf/get :quotes-media-storage-bytes-per-team (* 20 1024 1024 1024))) (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 7123917900..90178ab85c 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -26,6 +26,7 @@ [app.features.fdata :as fdata] [app.features.file-snapshots :as fsnap] [app.http.session :as session] + [app.jobs :as jobs] [app.loggers.audit :as audit] [app.msgbus :as mbus] [app.rpc.commands.auth :as auth] @@ -39,7 +40,6 @@ [app.system :as sys] [app.util.blob :as blob] [app.util.pointer-map :as pmap] - [app.worker :as wrk] [clojure.datafy :refer [datafy]] [clojure.java.io :as io] [clojure.pprint :refer [print-table]] @@ -56,38 +56,39 @@ ;; TASKS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn print-tasks +(defn print-jobs [] - (let [tasks (:app.worker/registry sys/system)] - (pp/pprint (keys tasks) :level 200))) + (let [jobs (:app.jobs/defs sys/system)] + (pp/pprint (into (sorted-set) (keys jobs))))) -(defn run-task! +(defn run-job + "Execute the job handler in-process (no job row)." ([tname] - (run-task! tname {})) + (run-job tname {})) ([tname params] - (wrk/invoke! (-> sys/system - (assoc ::wrk/task tname) - (assoc ::wrk/params params))))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name tname) + (assoc ::jobs/params params))))) -(defn schedule-task! +(defn submit-job + "Submit a durable job to the queue." ([name] - (schedule-task! name {})) + (submit-job name {})) ([name params] - (wrk/submit! (-> sys/system - (assoc ::wrk/task name) - (assoc ::wrk/params params))))) + (jobs/submit sys/system + {::jobs/name name + ::jobs/params params}))) -(defn send-test-email! +(defn send-test-email [destination] (assert (string? destination) "destination should be provided") - (-> sys/system - (assoc ::wrk/task :sendmail) - (assoc ::wrk/params {:body "test email" - :subject "test email" - :to [destination]}) - (wrk/invoke!))) + (jobs/submit sys/system + {::jobs/name :sendmail + ::jobs/params {:body "test email" + :subject "test email" + :to [destination]}})) -(defn resend-email-verification-email! +(defn resend-email-verification-email [email] (db/tx-run! sys/system (fn [{:keys [::db/conn] :as cfg}] @@ -177,11 +178,11 @@ :context {:triggered-by "srepl" :cause cause}}) - (wrk/invoke! (-> system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :profile - :deleted-at deleted-at - :id (:id profile)}))) + (jobs/invoke (-> system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :profile + :deleted-at deleted-at + :id (:id profile)}))) (:id profile))) (defn delete-profiles-by-email! @@ -667,11 +668,11 @@ :context {:triggered-by "srepl" :cause "explicit call to delete-file!"} :tracked-at tnow}) - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :file - :deleted-at tnow - :id file-id}))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :file + :deleted-at tnow + :id file-id}))) :deleted)) (defn restore-file! @@ -708,11 +709,11 @@ :cause "explicit call to delete-project!"} :tracked-at tnow}) - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :project - :deleted-at tnow - :id project-id}))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :project + :deleted-at tnow + :id project-id}))) :deleted)) (defn- restore-project* @@ -760,11 +761,11 @@ :cause "explicit call to delete-profile!"} :tracked-at tnow}) - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :team - :deleted-at tnow - :id team-id}))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :team + :deleted-at tnow + :id team-id}))) :deleted)) (defn- restore-team* @@ -816,11 +817,11 @@ :cause "explicit call to delete-profile!"} :tracked-at tnow}) - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :profile - :deleted-at tnow - :id profile-id}))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :profile + :deleted-at tnow + :id profile-id}))) :deleted)) (defn restore-profile! @@ -879,41 +880,41 @@ [] (->> (db/exec! sys/system ["select id, deleted_at from profile where deleted_at is not null"]) (run! (fn [{:keys [id deleted-at]}] - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :profile - :deleted-at deleted-at - :id id}))))))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :profile + :deleted-at deleted-at + :id id}))))))) (defn process-deleted-teams-cascade [] (->> (db/exec! sys/system ["select id, deleted_at from team where deleted_at is not null"]) (run! (fn [{:keys [id deleted-at]}] - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :team - :deleted-at deleted-at - :id id}))))))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :team + :deleted-at deleted-at + :id id}))))))) (defn process-deleted-projects-cascade [] (->> (db/exec! sys/system ["select id, deleted_at from project where deleted_at is not null"]) (run! (fn [{:keys [id deleted-at]}] - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :project - :deleted-at deleted-at - :id id}))))))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :project + :deleted-at deleted-at + :id id}))))))) (defn process-deleted-files-cascade [] (->> (db/exec! sys/system ["select id, deleted_at from file where deleted_at is not null"]) (run! (fn [{:keys [id deleted-at]}] - (wrk/invoke! (-> sys/system - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :file - :deleted-at deleted-at - :id id}))))))) + (jobs/invoke (-> sys/system + (assoc ::jobs/name :delete-object) + (assoc ::jobs/params {:object :file + :deleted-at deleted-at + :id id}))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SSO diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index 25c2d3482a..6c7a87762b 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -48,6 +48,13 @@ "Bucket name for chunked-upload chunks." stsch/upload-session-bucket) +(def job-resource-bucket + "Bucket for storage objects referenced by job.resource_id (the + unified jobs substrate): transient artifacts owned by their job row, + reclaimed by storage-gc-touched (via the jobs GC touch) once no job + row references them anymore." + stsch/job-resource-bucket) + (def valid-buckets stsch/metadata-buckets) diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index 7048bd4d2e..d718ccdb89 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -15,8 +15,10 @@ (:require [app.common.data :as d] [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] + [app.jobs :as jobs] [app.storage :as sto] [app.storage.impl :as impl] [clojure.set :as set] @@ -172,18 +174,42 @@ (+ acc (process-chunk conn storage backend-id ids))) 0 by-backend))))))] + ;; Heartbeat per chunk: each chunk commits on its own, so a long + ;; sweep neither loses work on late failure nor outruns the lease. + (jobs/heartbeat cfg) (if deleted (recur (+ total deleted)) total)))) -(defmethod ig/assert-key ::handler +(declare execute-storage-gc-deleted) + +(defmethod ig/assert-key ::job-def [_ params] (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage") (assert (db/pool? (::db/pool params)) "expect valid db pool")) -(defmethod ig/init-key ::handler +(def schema:storage-gc-deleted-params + "Params map (no params needed; cfg-provided config only)." + [:map {:closed true}]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [_] - (let [total (clean-deleted cfg)] - (l/inf :hint "task finished" :total total) - {:deleted total}))) + {::jobs/name :storage-gc-deleted + ::jobs/schema schema:storage-gc-deleted-params + ::jobs/handler + (fn [_context params] + (execute-storage-gc-deleted cfg params)) + ::jobs/decoder (sm/decoder schema:storage-gc-deleted-params sm/json-transformer) + ::jobs/validator (sm/validator schema:storage-gc-deleted-params)}) + +(defn execute-storage-gc-deleted + "Plain job handler: clean the marked-deleted storage objects." + ([cfg] (execute-storage-gc-deleted cfg {})) + ([cfg _params] + ;; NOTE: no outer transaction here on purpose — clean-deleted + ;; commits each chunk in its own transaction, so a late failure + ;; only loses the in-flight chunk. + (jobs/heartbeat cfg) + (let [total (clean-deleted cfg)] + (l/inf :hint "task finished" :total total) + {:deleted total}))) diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index f73b6024f1..82394e8f7b 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -22,8 +22,10 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] + [app.jobs :as jobs] [app.metrics :as mtx] [app.storage :as sto] [app.storage.impl :as impl] @@ -68,8 +70,7 @@ (-> (db/exec-one! conn [sql:has-file-object-thumbnail-refs id]) (get :has-refs))) -(def ^:private - sql:has-file-thumbnail-refs +(def ^:private sql:has-file-thumbnail-refs "SELECT EXISTS (SELECT 1 FROM file_thumbnail WHERE media_id = ?) AS has_refs") (defn- has-file-thumbnails-refs? @@ -77,6 +78,24 @@ (-> (db/exec-one! conn [sql:has-file-thumbnail-refs id]) (get :has-refs))) +;; Objects in the job-resource bucket are referenced by the resource_id +;; column of job rows (the unified jobs substrate). A live job row of +;; any status keeps the object frozen; once no row references it (the +;; jobs GC deletes expiring/retained rows and marks the object as +;; touched before/at the same time) the object becomes deletable. + +(def ^:private sql:has-job-resource-refs + "SELECT EXISTS (SELECT 1 FROM job WHERE resource_id = ?) AS has_refs") + +(defn- has-job-resource-refs? + "Checks if ANY job row (any status) references the object. Terminal states + (completed, failed, cancelled, aborted) also freeze the object because the jobs GC + hasn't run yet to clean them up. Once the jobs GC deletes the row, the + object becomes eligible for storage GC." + [conn {:keys [id]}] + (-> (db/exec-one! conn [sql:has-job-resource-refs id]) + (get :has-refs))) + (def sql:exists-file-data-refs "SELECT EXISTS ( SELECT 1 FROM file_data @@ -179,6 +198,7 @@ (= bucket "file-data") (process-objects conn has-file-data-refs? bucket objects) (= bucket sto/tempfile-bucket) (process-objects conn (constantly false) sto/tempfile-bucket objects) (= bucket sto/upload-session-bucket) (process-objects conn (constantly false) sto/upload-session-bucket objects) + (= bucket sto/job-resource-bucket) (process-objects conn has-job-resource-refs? bucket objects) (= bucket "organization") (process-objects conn (constantly false) bucket objects) :else (ex/raise :type :internal @@ -247,6 +267,7 @@ (let [[nfo ndo] (if (seq chunk) (db/tx-run! cfg process-chunk chunk) [0 0])] + (jobs/heartbeat cfg) (recur (long (+ freezed nfo)) (long (+ deleted ndo)))) {:freeze freezed :delete deleted})))) @@ -255,20 +276,40 @@ ;; HANDLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defmethod ig/assert-key ::handler +(declare execute-storage-gc-touched) + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expect valid storage") (assert (mtx/metrics? (::mtx/metrics params)) "expect valid metrics")) -(defmethod ig/expand-key ::handler +(defmethod ig/expand-key ::job-def [k v] {k (merge {::min-age (ct/duration {:hours 2})} v)}) -(defmethod ig/init-key ::handler - [_ {:keys [::min-age] :as cfg}] - (fn [{:keys [props]}] - (let [threshold (if (:skip-delay props) - (ct/now) - (ct/minus (ct/now) min-age))] - (process-touched (assoc cfg ::timestamp threshold))))) +(def schema:storage-gc-touched-params + "Optional :skip-delay processes all touched objects immediately, + bypassing the min-age threshold (repl-driven deletion cascades)." + [:map {:closed true} + [:skip-delay {:optional true} :boolean]]) + +(defmethod ig/init-key ::job-def + [_ cfg] + {::jobs/name :storage-gc-touched + ::jobs/schema schema:storage-gc-touched-params + ::jobs/handler + (fn [_context params] + (execute-storage-gc-touched cfg params)) + ::jobs/decoder (sm/decoder schema:storage-gc-touched-params sm/json-transformer) + ::jobs/validator (sm/validator schema:storage-gc-touched-params)}) + +(defn execute-storage-gc-touched + "Plain job handler: analyze the touched storage objects and freeze or + delete them depending on their references." + ([cfg] (execute-storage-gc-touched cfg {})) + ([cfg params] + (let [threshold (if (:skip-delay params) + (ct/now) + (ct/minus (ct/now) (::min-age cfg)))] + (process-touched (assoc cfg ::timestamp threshold))))) diff --git a/backend/src/app/storage/pending_gc.clj b/backend/src/app/storage/pending_gc.clj index df161f40c9..ff90e34f9a 100644 --- a/backend/src/app/storage/pending_gc.clj +++ b/backend/src/app/storage/pending_gc.clj @@ -14,7 +14,9 @@ ever iterating the whole physical store." (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.db :as db] + [app.jobs :as jobs] [app.storage :as sto] [app.storage.impl :as impl] [integrant.core :as ig])) @@ -75,14 +77,33 @@ (recur (long (+ total (count rows))))) total))) -(defmethod ig/assert-key ::handler +(declare execute-storage-pending-gc) + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected valid db pool") (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage")) -(defmethod ig/init-key ::handler +(def schema:storage-pending-gc-params + "Params map (no params needed; cfg-provided config only)." + [:map {:closed true}]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [_] - (let [total (process! cfg)] - (l/inf :hint "task finished" :total total) - {:processed total}))) + {::jobs/name :storage-pending-gc + ::jobs/schema schema:storage-pending-gc-params + ::jobs/handler + (fn [_context params] + (execute-storage-pending-gc cfg params)) + ::jobs/decoder (sm/decoder schema:storage-pending-gc-params sm/json-transformer) + ::jobs/validator (sm/validator schema:storage-pending-gc-params)}) + +(defn execute-storage-pending-gc + "Plain job handler: reclaim storage objects created in 'pending' state + that were never promoted to 'valid'." + ([cfg] (execute-storage-pending-gc cfg {})) + ([cfg _params] + (jobs/heartbeat cfg) + (let [total (process! cfg)] + (l/inf :hint "task finished" :total total) + {:processed total}))) diff --git a/backend/src/app/storage/schema.clj b/backend/src/app/storage/schema.clj index 2ded471780..17e858b972 100644 --- a/backend/src/app/storage/schema.clj +++ b/backend/src/app/storage/schema.clj @@ -33,6 +33,11 @@ "Bucket name for chunked-upload chunks." "upload-session") +(def job-resource-bucket + "Bucket name for storage objects owned by a job row + (`job.resource_id`)." + "job-resource") + (def bucket-requirements "Canonical buckets and the extra keys each one must carry, so a new bucket and its contract live in one place. Listed keys are declared as @@ -48,6 +53,7 @@ "organization" #{:organization-id} tempfile-bucket #{} upload-session-bucket #{} + job-resource-bucket #{} "file-data" #{:file-id :id} "file-data-fragment" #{} "file-change" #{}}) diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index fad5b54a36..730a2364c9 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -8,9 +8,11 @@ "A generic task for object deletion cascade handling" (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] [app.db.sql :as-alias sql] + [app.jobs :as jobs] [app.rpc.commands.files :as files] [app.rpc.commands.profile :as profile] [app.storage :as sto] @@ -140,6 +142,7 @@ {::db/return-keys false}) (doseq [team (profile/get-owned-teams conn id)] + (jobs/heartbeat cfg) (delete-object cfg (assoc team :object :team :deleted-at deleted-at)))) @@ -148,12 +151,30 @@ [_cfg props] (l/wrn :obj (:object props) :hint "not implementation found")) -(defmethod ig/assert-key ::handler +(def schema:delete-object-params + [:map + [:object [:enum :snapshot :team :project :profile :file]] + [:deleted-at ::ct/inst] + [:id ::sm/uuid] + [:file-id {:optional true} ::sm/uuid]]) + +(defn execute-delete-object + "Plain job handler: run the delete-object multimethod on the provided + params inside a single transaction." + [cfg params] + (db/tx-run! cfg delete-object params)) + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool") (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided")) -(defmethod ig/init-key ::handler +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props] :as task}] - (db/tx-run! cfg delete-object props))) + {::jobs/name :delete-object + ::jobs/schema schema:delete-object-params + ::jobs/handler + (fn [_context params] + (execute-delete-object cfg params)) + ::jobs/decoder (sm/decoder schema:delete-object-params sm/json-transformer) + ::jobs/validator (sm/validator schema:delete-object-params)}) diff --git a/backend/src/app/tasks/demo_purge.clj b/backend/src/app/tasks/demo_purge.clj index a77fb36e09..4427f7b83e 100644 --- a/backend/src/app/tasks/demo_purge.clj +++ b/backend/src/app/tasks/demo_purge.clj @@ -9,33 +9,47 @@ creation time with a delay matching the configured deletion-delay." (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] - [app.worker :as wrk] + [app.jobs :as jobs] [integrant.core :as ig])) -(defmethod ig/assert-key ::handler +(def schema:demo-purge-params + [:map + [:profile-id ::sm/uuid]]) +(declare execute-demo-purge) + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool")) -(defmethod ig/init-key ::handler +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props]}] - (let [profile-id (get props :profile-id) - now (ct/now)] + {::jobs/name :demo-purge + ::jobs/schema schema:demo-purge-params + ::jobs/handler + (fn [_context params] + (execute-demo-purge cfg params)) + ::jobs/decoder (sm/decoder schema:demo-purge-params sm/json-transformer) + ::jobs/validator (sm/validator schema:demo-purge-params)}) - (l/trc :hint "demo-purge" :profile-id (str profile-id)) +(defn execute-demo-purge + "Plain job handler: mark the demo profile as deleted and submit the + corresponding delete-object job." + [cfg params] + (let [profile-id (:profile-id params) + now (ct/now)] + (l/trc :hint "demo-purge" :profile-id (str profile-id)) - ;; Mark the profile for immediate deletion - (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (db/update! conn :profile - {:deleted-at now} - {:id profile-id} - {::db/return-keys false}) - (wrk/submit! - (-> cfg - (assoc ::wrk/task :delete-object) - (assoc ::wrk/params {:object :profile - :deleted-at now - :id profile-id})))))))) + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (db/update! conn :profile + {:deleted-at now} + {:id profile-id} + {::db/return-keys false}) + (jobs/submit cfg + {::jobs/name :delete-object + ::jobs/params {:object :profile + :deleted-at now + :id profile-id}}))))) diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 78461e9b99..3d77b21045 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -15,6 +15,7 @@ [app.common.files.helpers :as cfh] [app.common.files.validate :as cfv] [app.common.logging :as l] + [app.common.schema :as sm] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.components-list :as ctkl] @@ -24,8 +25,8 @@ [app.db :as db] [app.features.fdata :as feat.fdata] [app.features.file-snapshots :as fsnap] + [app.jobs :as jobs] [app.storage :as sto] - [app.worker :as wrk] [integrant.core :as ig])) (declare get-file) @@ -57,6 +58,7 @@ (->> (db/exec! conn [sql:mark-file-media-object-deleted timestamp id used-media]) (into #{} (map :id)))] + (jobs/heartbeat cfg) (doseq [id unused-media] (l/trc :obj "media-object" :file-id (str id) @@ -71,7 +73,7 @@ RETURNING object_id") (defn- clean-file-object-thumbnails! - [{:keys [::db/conn ::timestamp]} {:keys [data] :as file}] + [{:keys [::db/conn ::timestamp] :as cfg} {:keys [data] :as file}] (let [file-id (:id file) using (->> (vals (:pages-index data)) (into #{} (comp @@ -87,6 +89,7 @@ unused (->> (db/exec! conn [sql:mark-file-object-thumbnails-deleted timestamp file-id ids]) (into #{} (map :object-id)))] + (jobs/heartbeat cfg) (doseq [object-id unused] (l/trc :obj "object-thumbnail" :file-id (str file-id) @@ -101,10 +104,11 @@ RETURNING revn") (defn- clean-file-thumbnails! - [{:keys [::db/conn ::timestamp]} {:keys [id revn] :as file}] + [{:keys [::db/conn ::timestamp] :as cfg} {:keys [id revn] :as file}] (let [unused (->> (db/exec! conn [sql:mark-file-thumbnails-deleted timestamp id revn]) (into #{} (map :revn)))] + (jobs/heartbeat cfg) (doseq [revn unused] (l/trc :obj "thumbnail" :file-id (str id) @@ -182,12 +186,13 @@ (mapcat feat.fdata/get-used-pointer-ids))) (defn- clean-fragments! - [{:keys [::db/conn ::timestamp]} {:keys [id] :as file}] + [{:keys [::db/conn ::timestamp] :as cfg} {:keys [id] :as file}] (let [used (into #{} xf:collect-pointers [file]) unused (->> (db/exec! conn [sql:mark-deleted-data-fragments timestamp id (db/create-array conn "uuid" used)]) (into #{} bfc/xf-map-id))] + (jobs/heartbeat cfg) (doseq [id unused] (l/trc :obj "fragment" :file-id (str id) @@ -240,28 +245,41 @@ ;; HANDLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defmethod ig/assert-key ::handler - [_ params] - (assert (db/pool? (::db/pool params)) "expected a valid database pool") - (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided")) +(defn execute-file-gc + "Plain job handler: clean the media/thumbnails/fdata of one file." + [cfg params] + (try + (-> cfg + (assoc ::db/rollback (:rollback? params)) + (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] + (let [cfg (-> cfg + (update ::sto/storage sto/configure conn) + (assoc ::timestamp (ct/now))) + processed? (process-file! cfg params)] -(defmethod ig/init-key ::handler + (when (and processed? (contains? cf/flags :tiered-file-data-storage)) + (jobs/submit cfg + {::jobs/name :offload-file-data + ::jobs/params params + ::jobs/priority 10 + ::jobs/delay 1000})) + processed?)))) + (catch Throwable cause + (l/err :hint "error on cleaning file" + :file-id (str (:file-id params)) + :cause cause)))) + +(def schema:file-gc-params + [:map + [:file-id ::sm/uuid] + [:revn ::sm/int]]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props] :as task}] - (try - (-> cfg - (assoc ::db/rollback (:rollback? props)) - (assoc ::timestamp (ct/now)) - (db/tx-run! (fn [cfg] - (let [processed? (process-file! cfg props)] - (when (and processed? (contains? cf/flags :tiered-file-data-storage)) - (wrk/submit! (-> cfg - (assoc ::wrk/task :offload-file-data) - (assoc ::wrk/params props) - (assoc ::wrk/priority 10) - (assoc ::wrk/delay 1000)))) - processed?)))) - (catch Throwable cause - (l/err :hint "error on cleaning file" - :file-id (str (:file-id props)) - :cause cause))))) + {::jobs/name :file-gc + ::jobs/schema schema:file-gc-params + ::jobs/handler + (fn [_context params] + (execute-file-gc cfg params)) + ::jobs/decoder (sm/decoder schema:file-gc-params sm/json-transformer) + ::jobs/validator (sm/validator schema:file-gc-params)}) diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj index e70703afaa..7eaa3801cc 100644 --- a/backend/src/app/tasks/file_gc_scheduler.clj +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -9,10 +9,11 @@ file-gc task for all files that matches the eligibility threshold." (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.config :as cf] [app.db :as db] - [app.worker :as wrk] + [app.jobs :as jobs] [integrant.core :as ig])) (def ^:private @@ -36,29 +37,50 @@ :file-id (str id) :revn revn :modified-at (ct/format-inst modified-at)) - (wrk/submit! (assoc cfg ::wrk/params params)) + (jobs/submit cfg + {::jobs/name :file-gc + ::jobs/params params + ::jobs/priority 10 + ::jobs/delay 10000}) + (jobs/heartbeat cfg) (inc total))) 0 (db/plan conn [sql:get-candidates threshold] {:fetch-size 10}))] {:processed total})) -(defmethod ig/assert-key ::handler +(declare execute-file-gc-scheduler) + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool")) -(defmethod ig/expand-key ::handler +(defmethod ig/expand-key ::job-def [k v] {k (assoc v ::min-age (cf/get-file-clean-delay))}) -(defmethod ig/init-key ::handler +(def schema:file-gc-scheduler-params + "min-age: int millis or text over the job pipeline, duration object + in-process (repl runs)." + [:map + [:min-age {:optional true} [:or :int :string ::ct/duration]]]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props] :as task}] - (let [threshold (-> (ct/duration (or (:min-age props) (::min-age cfg))) - (ct/in-past))] - (-> cfg - (assoc ::db/rollback (:rollback? props)) - (assoc ::wrk/task :file-gc) - (assoc ::wrk/priority 10) - (assoc ::wrk/mark-retries 0) - (assoc ::wrk/delay 10000) - (db/tx-run! schedule! threshold))))) + {::jobs/name :file-gc-scheduler + ::jobs/schema schema:file-gc-scheduler-params + ::jobs/handler + (fn [_context params] + (execute-file-gc-scheduler cfg params)) + ::jobs/decoder (sm/decoder schema:file-gc-scheduler-params sm/json-transformer) + ::jobs/validator (sm/validator schema:file-gc-scheduler-params)}) + +(defn execute-file-gc-scheduler + "Plain job handler: select the file gc candidates (media-trim pending + files before threshold) and schedule one file-gc job per file; no props + needed (min-age default from config; overridable for the repl runs)." + [cfg params] + (let [threshold (-> (ct/duration (or (:min-age params) (::min-age cfg))) + (ct/in-past))] + (-> cfg + (assoc ::db/rollback (:rollback? params)) + (db/tx-run! schedule! threshold)))) diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index daf5619ae6..0a8ea36bac 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -9,9 +9,11 @@ of deleted or unreachable objects." (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] [app.features.fdata :as fdata] + [app.jobs :as jobs] [app.storage :as sto] [app.tasks.delete-object :as dobj] [integrant.core :as ig])) @@ -44,9 +46,9 @@ pending purge. Referenced storage objects are touched so the storage GC reclaims them with its usual delay; chunk mappings are removed before the session row (NO ACTION foreign keys)." - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (let [stalled-threshold (ct/minus timestamp {:hours 1})] - (->> (db/plan conn [sql:get-upload-sessions timestamp stalled-threshold timestamp chunk-size] + [{:keys [::db/conn ::threshold ::chunk-size ::sto/storage] :as cfg}] + (let [stalled-threshold (ct/minus threshold {:hours 1})] + (->> (db/plan conn [sql:get-upload-sessions threshold stalled-threshold threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id]}] (l/trc :obj "upload-session" :id (str id)) @@ -71,8 +73,8 @@ SKIP LOCKED") (defn- delete-profiles! - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (->> (db/plan conn [sql:get-profiles timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::timestamp ::threshold ::chunk-size ::sto/storage] :as cfg}] + (->> (db/plan conn [sql:get-profiles threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id photo-id]}] (l/trc :obj "profile" :id (str id)) @@ -99,8 +101,8 @@ SKIP LOCKED") (defn- delete-teams! - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (->> (db/plan conn [sql:get-teams timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size ::sto/storage] :as cfg}] + (->> (db/plan conn [sql:get-teams threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id photo-id deleted-at]}] (l/trc :obj "team" :id (str id) @@ -126,8 +128,8 @@ SKIP LOCKED") (defn- delete-fonts! - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (->> (db/plan conn [sql:get-fonts timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size ::sto/storage] :as cfg}] + (->> (db/plan conn [sql:get-fonts threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id team-id deleted-at] :as font}] (l/trc :obj "font-variant" :id (str id) @@ -156,8 +158,8 @@ SKIP LOCKED") (defn- delete-projects! - [{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}] - (->> (db/plan conn [sql:get-projects timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size] :as cfg}] + (->> (db/plan conn [sql:get-projects threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id team-id deleted-at]}] (l/trc :obj "project" :id (str id) @@ -182,8 +184,8 @@ SKIP LOCKED") (defn- delete-files! - [{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}] - (->> (db/plan conn [sql:get-files timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size] :as cfg}] + (->> (db/plan conn [sql:get-files threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id deleted-at project-id] :as file}] (l/trc :obj "file" :id (str id) @@ -206,8 +208,8 @@ SKIP LOCKED") (defn delete-file-thumbnails! - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (->> (db/plan conn [sql:get-file-thumbnails timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size ::sto/storage] :as cfg}] + (->> (db/plan conn [sql:get-file-thumbnails threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [file-id revn media-id deleted-at]}] (l/trc :obj "file-thumbnail" :file-id (str file-id) @@ -233,8 +235,8 @@ SKIP LOCKED") (defn delete-file-object-thumbnails! - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (->> (db/plan conn [sql:get-file-object-thumbnails timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size ::sto/storage] :as cfg}] + (->> (db/plan conn [sql:get-file-object-thumbnails threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [file-id object-id media-id deleted-at]}] (l/trc :obj "file-object-thumbnail" :file-id (str file-id) @@ -261,8 +263,8 @@ SKIP LOCKED") (defn- delete-file-media-objects! - [{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}] - (->> (db/plan conn [sql:get-file-media-objects timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size ::sto/storage] :as cfg}] + (->> (db/plan conn [sql:get-file-media-objects threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id file-id deleted-at] :as fmo}] (l/trc :obj "file-media-object" :id (str id) @@ -289,8 +291,8 @@ SKIP LOCKED") (defn- delete-file-data! - [{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}] - (->> (db/plan conn [sql:get-file-data timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size] :as cfg}] + (->> (db/plan conn [sql:get-file-data threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [file-id id type deleted-at metadata backend]}] (some->> metadata @@ -323,8 +325,8 @@ SKIP LOCKED") (defn- delete-file-changes! - [{:keys [::db/conn ::timestamp ::chunk-size] :as cfg}] - (->> (db/plan conn [sql:get-file-change timestamp chunk-size] {:fetch-size 5}) + [{:keys [::db/conn ::threshold ::chunk-size] :as cfg}] + (->> (db/plan conn [sql:get-file-change threshold chunk-size] {:fetch-size 5}) (reduce (fn [total {:keys [id file-id deleted-at] :as xlog}] (l/trc :obj "file-change" :id (str id) @@ -352,9 +354,12 @@ #'delete-fonts! #'delete-teams!]) -(defn- execute-proc! +(defn- execute-proc "A generic function that executes the specified proc iterativelly - until 0 results is returned" + until 0 results is returned. Heartbeats after each committed chunk so + a long backlog in a single proc cannot outrun the orphan lease + (mirrors storage/gc-deleted clean-deleted, which heartbeats per + chunk)." [cfg proc-fn] (loop [total 0] (let [result (db/tx-run! cfg @@ -362,32 +367,59 @@ (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) (proc-fn cfg)))] (if (pos? result) - (recur (long (+ total result))) + (do + (jobs/heartbeat cfg) + (recur (long (+ total result)))) total)))) -(defmethod ig/assert-key ::handler + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool") (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided")) -(defmethod ig/expand-key ::handler +(defmethod ig/expand-key ::job-def [k v] {k (assoc v ::chunk-size 100)}) +(declare execute-objects-gc) -(defmethod ig/init-key ::handler +(def schema:objects-gc-params + "Optional :skip-delay processes everything immediately, ignoring + deleted-at schedules (operator/repl escape hatch, mirrors + storage-gc-touched). Optional :chunk-size overrides the per-proc batch + size from config." + [:map {:closed true} + [:skip-delay {:optional true} :boolean] + [:chunk-size {:optional true} :int]]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props]}] - (let [skip-delay (:skip-delay props) - chunk-size (or (:chunk-size props) (::chunk-size cfg)) - cfg (-> cfg - (assoc ::chunk-size chunk-size) - (assoc ::timestamp (if skip-delay - (ct/in-future {:days 3650}) - (ct/now))))] - (loop [procs (map deref deletion-proc-vars) - total 0] - (if-let [proc-fn (first procs)] - (let [result (execute-proc! cfg proc-fn)] - (recur (rest procs) - (long (+ total result)))) - {:processed total}))))) + {::jobs/name :objects-gc + ::jobs/schema schema:objects-gc-params + ::jobs/handler + (fn [_context params] + (execute-objects-gc cfg params)) + ::jobs/decoder (sm/decoder schema:objects-gc-params sm/json-transformer) + ::jobs/validator (sm/validator schema:objects-gc-params)}) + +(defn execute-objects-gc + "Plain job handler: garbage collect orphan storage objects." + ([cfg] (execute-objects-gc cfg {})) + ([cfg params] + (let [now (ct/now) + threshold (if (:skip-delay params) + (ct/in-future {:days 3650}) + now) + cfg (assoc cfg + ::timestamp now + ::threshold threshold + ::chunk-size (or (:chunk-size params) + (::chunk-size cfg)))] + (loop [procs (map deref deletion-proc-vars) + total 0] + (if-let [proc-fn (first procs)] + (let [result (execute-proc cfg proc-fn)] + (jobs/heartbeat cfg) + (recur (rest procs) + (long (+ total result)))) + {:processed total}))))) diff --git a/backend/src/app/tasks/offload_file_data.clj b/backend/src/app/tasks/offload_file_data.clj index 72dfd44be7..fae8b2d75d 100644 --- a/backend/src/app/tasks/offload_file_data.clj +++ b/backend/src/app/tasks/offload_file_data.clj @@ -9,8 +9,10 @@ storage (the database row) to a cold storage (fs or s3)." (:require [app.common.logging :as l] + [app.common.schema :as sm] [app.db :as db] [app.features.fdata :as fdata] + [app.jobs :as jobs] [app.storage :as sto] [integrant.core :as ig])) @@ -23,6 +25,7 @@ (defn- offload-file-data [cfg {:keys [id file-id type] :as fdata}] + (jobs/heartbeat cfg) (fdata/upsert! cfg (assoc fdata :backend "storage")) (l/trc :file-id (str file-id) :id (str id) @@ -32,17 +35,31 @@ ;; HANDLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defmethod ig/assert-key ::handler +(defn execute-offload-file-data + "Plain job handler: offload the file data rows of one file." + [cfg params] + (let [file-id (:file-id params)] + (-> cfg + (assoc ::db/rollback (:rollback? params)) + (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] + (run! (partial offload-file-data cfg) + (db/plan conn [sql:get-file-data file-id]))))))) + +(def schema:offload-file-data-params + [:map + [:file-id ::sm/uuid]]) + +(defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool") (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided")) -(defmethod ig/init-key ::handler +(defmethod ig/init-key ::job-def [_ cfg] - (fn [{:keys [props] :as task}] - (let [file-id (:file-id props)] - (-> cfg - (assoc ::db/rollback (:rollback? props)) - (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] - (run! (partial offload-file-data cfg) - (db/plan conn [sql:get-file-data file-id])))))))) + {::jobs/name :offload-file-data + ::jobs/schema schema:offload-file-data-params + ::jobs/handler + (fn [_context params] + (execute-offload-file-data cfg params)) + ::jobs/decoder (sm/decoder schema:offload-file-data-params sm/json-transformer) + ::jobs/validator (sm/validator schema:offload-file-data-params)}) diff --git a/backend/src/app/tasks/tasks_gc.clj b/backend/src/app/tasks/tasks_gc.clj deleted file mode 100644 index ee84e37714..0000000000 --- a/backend/src/app/tasks/tasks_gc.clj +++ /dev/null @@ -1,39 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS SUBSIDIARY SL - -(ns app.tasks.tasks-gc - "A maintenance task that performs a cleanup of already executed tasks - from the database table." - (:require - [app.common.logging :as l] - [app.config :as cf] - [app.db :as db] - [integrant.core :as ig])) - -(def ^:private - sql:delete-completed-tasks - "DELETE FROM task WHERE scheduled_at < now() - ?::interval") - -(defmethod ig/assert-key ::handler - [_ params] - (assert (db/pool? (::db/pool params)) "expected a valid database pool")) - -(defmethod ig/expand-key ::handler - [k v] - {k (assoc v ::min-age (cf/get-deletion-delay))}) - -(defmethod ig/init-key ::handler - [_ {:keys [::min-age] :as cfg}] - (fn [{:keys [props] :as task}] - (let [min-age (or (:min-age props) min-age)] - (-> cfg - (assoc ::db/rollback (:rollback? props)) - (db/tx-run! (fn [{:keys [::db/conn]}] - (let [interval (db/interval min-age) - result (db/exec-one! conn [sql:delete-completed-tasks interval]) - result (db/get-update-count result)] - (l/debug :hint "task finished" :total result) - result))))))) diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 254ca5bf35..50029746e3 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -12,9 +12,11 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.schema :as sm] [app.config :as cf] [app.db :as db] [app.http.client :as http] + [app.jobs :as jobs] [app.main :as-alias main] [app.setup :as-alias setup] [app.util.json :as json] @@ -300,58 +302,76 @@ ;; TASK ENTRY POINT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defmethod ig/assert-key ::handler +(declare execute-telemetry) + +(defmethod ig/assert-key ::job-def [_ params] (assert (http/client? (::http/client params)) "expected a valid http client") (assert (db/pool? (::db/pool params)) "expected a valid database pool") (assert (some? (::setup/props params)) "expected setup props to be available")) -(defmethod ig/init-key ::handler +(def schema:telemetry-params + [:map + [:send? {:optional true} :boolean] + [:enabled? {:optional true} :boolean]]) + +(defmethod ig/init-key ::job-def [_ cfg] - (fn [task] - (let [params (:props task) - send? (get params :send? true) - enabled? (or (get params :enabled? false) - (contains? cf/flags :telemetry)) - ;; Deferred so the query runs only when a report is - ;; actually going to be sent. - subs (delay (get-subscriptions cfg))] + {::jobs/name :telemetry + ::jobs/schema schema:telemetry-params + ::jobs/handler + (fn [_context params] + (execute-telemetry cfg params)) + ::jobs/decoder (sm/decoder schema:telemetry-params sm/json-transformer) + ::jobs/validator (sm/validator schema:telemetry-params)}) - ;; If we have telemetry enabled, then proceed the normal - ;; operation sending legacy report +(defn execute-telemetry + "Plain job handler: send periodic telemetry data." + [cfg params] + (let [send? (get params :send? true) + enabled? (or (get params :enabled? false) + (contains? cf/flags :telemetry)) + ;; Deferred so the query runs only when a report is + ;; actually going to be sent. + subs (delay (get-subscriptions cfg))] - (if enabled? - (when send? - (db/run! cfg gc-events) - ;; Randomize start time to avoid thundering herd when multiple - ;; instances restart at the same time. - (px/sleep (rand-int 10000)) + (jobs/heartbeat cfg) + ;; If we have telemetry enabled, then proceed the normal + ;; operation sending legacy report + + (if enabled? + (when send? + (db/run! cfg gc-events) + ;; Randomize start time to avoid thundering herd when multiple + ;; instances restart at the same time. + (px/sleep (rand-int 10000)) + + (try + (let [stats (db/run! cfg get-legacy-stats)] + (send-legacy-data cfg stats @subs)) + (catch Exception cause + (l/wrn :hint "unable to send legacy report" + :cause cause))) + + ;; Ship any anonymous audit-log events accumulated in + ;; telemetry mode (only when audit-log feature is off). + (when-not (contains? cf/flags :audit-log) (try - (let [stats (db/run! cfg get-legacy-stats)] - (send-legacy-data cfg stats @subs)) + (db/run! cfg collect-and-send-audit-events) (catch Exception cause - (l/wrn :hint "unable to send legacy report" - :cause cause))) + (l/wrn :hint "unable to send events" + :cause cause))))) - ;; Ship any anonymous audit-log events accumulated in - ;; telemetry mode (only when audit-log feature is off). - (when-not (contains? cf/flags :audit-log) - (try - (db/run! cfg collect-and-send-audit-events) - (catch Exception cause - (l/wrn :hint "unable to send events" - :cause cause))))) - - ;; If we have telemetry disabled, but there are users that are - ;; explicitly checked the newsletter subscription on the - ;; onboarding dialog or the profile section, then proceed to - ;; send a limited telemetry data, that consists in the list of - ;; subscribed emails and the running penpot version. - ;; Official instances (penpot.dev / penpot.app) are excluded: - ;; they must never send subscriber emails to the telemetry - ;; endpoint (which is ourselves). - (when (and (not (cf/telemetry-excluded?)) send? @subs) - (px/sleep (rand-int 10000)) - (ex/ignoring - (send-legacy-data cfg nil @subs))))))) + ;; If we have telemetry disabled, but there are users that are + ;; explicitly checked the newsletter subscription on the + ;; onboarding dialog or the profile section, then proceed to + ;; send a limited telemetry data, that consists in the list of + ;; subscribed emails and the running penpot version. + ;; Official instances (penpot.dev / penpot.app) are excluded: + ;; they must never send subscriber emails to the telemetry + ;; endpoint (which is ourselves). + (when (and (not (cf/telemetry-excluded?)) send? @subs) + (px/sleep (rand-int 10000)) + (ex/ignoring + (send-legacy-data cfg nil @subs)))))) diff --git a/backend/src/app/worker.clj b/backend/src/app/worker.clj index 09fc741ea0..26c314c7e7 100644 --- a/backend/src/app/worker.clj +++ b/backend/src/app/worker.clj @@ -5,145 +5,25 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker - "Async tasks abstraction (impl)." + "Conventions shared by the job execution machinery (impl). + + Handlers that fail with a transient error can throw an exception with + `{:type ::retry}` in ex-data (optionally `:delay` with a duration or + milliseconds, and `:strategy ::noop` to retry without counting the + attempt); the runner will schedule the job for retry (respecting + max-retries) with exponential backoff. The public submit/invoke API + lives in `app.jobs`." (:require - [app.common.data :as d] - [app.common.logging :as l] - [app.common.schema :as sm] - [app.common.time :as ct] - [app.common.uuid :as uuid] - [app.config :as cf] - [app.db :as db] - [app.metrics :as mtx] - [cuerdas.core :as str] - [integrant.core :as ig])) + [app.common.data :as d])) -(set! *warn-on-reflection* true) +(defn queue-key + "The Redis list the payloads of a queue travel through: + `penpot.worker.queue::`. -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TASKS REGISTRY -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defprotocol IRegistry - (get-task [_ name])) - -(defn- wrap-with-metrics - [f metrics tname] - (let [labels (into-array String [tname])] - (fn [params] - (let [tp (ct/tpoint)] - (try - (f params) - (finally - (mtx/run! metrics - {:id :tasks-timing - :val (inst-ms (tp)) - :labels labels}))))))) - -(def ^:private schema:tasks - [:map-of :keyword ::sm/fn]) - -(def ^:private valid-tasks? - (sm/validator schema:tasks)) - -(defmethod ig/assert-key ::registry - [_ params] - (assert (mtx/metrics? (::mtx/metrics params)) "expected valid metrics instance") - (assert (valid-tasks? (::tasks params)) "expected a valid map of tasks")) - -(defmethod ig/init-key ::registry - [_ {:keys [::mtx/metrics ::tasks]}] - (l/inf :hint "registry initialized" :tasks (count tasks)) - (let [tasks (reduce-kv (fn [registry k f] - (let [tname (name k)] - (l/trc :hint "register task" :name tname) - (assoc registry tname (wrap-with-metrics f metrics tname)))) - {} - tasks)] - (reify - clojure.lang.Counted - (count [_] (count tasks)) - - IRegistry - (get-task [_ name] - (get tasks (d/name name)))))) - -(sm/register! - {:type ::registry - :pred #(satisfies? IRegistry %)}) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SUBMIT API -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def ^:private sql:insert-new-task - "insert into task (id, name, props, queue, label, priority, max_retries, created_at, modified_at, scheduled_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - returning id") - -(def ^:private - sql:remove-not-started-tasks - "DELETE FROM task - WHERE name=? - AND queue=? - AND label=? - AND status = 'new' - AND scheduled_at > ?") - -(def ^:private schema:options - [:map {:title "submit-options"} - [::task [:or ::sm/text :keyword]] - [::label {:optional true} ::sm/text] - [::delay {:optional true} - [:or ::sm/int ::ct/duration]] - [::queue {:optional true} [:or ::sm/text :keyword]] - [::priority {:optional true} ::sm/int] - [::max-retries {:optional true} ::sm/int] - [::dedupe {:optional true} ::sm/boolean]]) - -(def check-options! - (sm/check-fn schema:options)) - -(defn submit! - [& {:keys [::params ::task ::delay ::queue ::priority ::max-retries ::dedupe ::label] - :or {delay 0 queue :default priority 100 max-retries 3 label ""} - :as options}] - - (check-options! options) - - (let [delay (ct/duration delay) - now (ct/now) - scheduled-at (-> (ct/plus now delay) - (ct/truncate :millisecond)) - props (db/tjson params) - id (uuid/next) - tenant (cf/get :tenant) - task (d/name task) - queue (str/ffmt "%:%" tenant (d/name queue)) - conn (db/get-connectable options) - deleted (when dedupe - (-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label now]) - (db/get-update-count)))] - - (l/trc :hint "submit task" - :name task - :task-id (str id) - :queue queue - :label label - :dedupe (boolean dedupe) - :delay (ct/format-duration delay) - :replace (or deleted 0)) - - (db/exec-one! conn [sql:insert-new-task id task props queue - label priority max-retries - now now scheduled-at]) - - id)) - -(defn invoke! - [{:keys [::task ::params] :as cfg}] - (assert (contains? cfg :app.worker/registry) - "missing worker registry on `cfg`") - (let [registry (get cfg ::registry) - task-fn (get-task registry task)] - (task-fn {:props params}))) + The tenant prefix is what keeps two instances sharing one Redis from + handing each other jobs, and `job.queue` stores the bare queue name, so + the prefix has to be composed here. Three callers push or pop on it (the + dispatcher, the runner and `app.jobs/request`), so the shape lives in + one place." + [tenant queue] + (str "penpot.worker.queue:" tenant ":" (d/name queue))) diff --git a/backend/src/app/worker/cron.clj b/backend/src/app/worker/cron.clj index f61f607bff..4e797006dd 100644 --- a/backend/src/app/worker/cron.clj +++ b/backend/src/app/worker/cron.clj @@ -5,17 +5,28 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.cron + "Cron pure scheduler: each tick claims the scheduled_task entry and + submits a system job (no profile_id, no user ledger) for it to the + dedicated `:cron` queue. Execution happens via the standard + dispatcher -> runner path, and so with the same lease, orphan + detection, retries and telemetry as for every other job. To keep the + no-overlap semantics (one entry is not re-fired while the previous + instance lives), the scheduler only submits when there is no active + instance (`new`/`scheduled`/`running`/`retry` with the same `name` + and `label` of the entry). The `FOR UPDATE SKIP LOCKED` claim on the + scheduled_task row already serializes the tick between nodes." (:require [app.common.data :as d] - [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] + [app.config :as cf] [app.db :as db] + [app.jobs :as jobs] + [app.jobs.metrics :as metrics] + [app.metrics :as-alias mtx] [app.util.cron :as cron] - [app.worker :as wrk] - [app.worker.runner :refer [get-error-context]] - [cuerdas.core :as str] + [app.worker :as-alias wrk] [integrant.core :as ig] [promesa.core :as p] [promesa.exec :as px]) @@ -30,7 +41,19 @@ on conflict (id) do nothing") -(defn- synchronize-cron-entries! +;; The tenant filter is what makes the no-overlap check per instance: the +;; job this count decides about is submitted to this tenant's queue, so +;; counting other tenants' active jobs would let one instance skip a tick +;; for work that is none of its business. +(def ^:private sql:count-active-jobs + "SELECT count(*) AS n + FROM job + WHERE name = ? + AND label = ? + AND tenant = ? + AND status IN ('new','scheduled','running','retry')") + +(defn- synchronize-cron-entries [{:keys [::db/conn ::entries]}] (doseq [{:keys [id cron]} entries] (let [result (db/exec-one! conn [sql:upsert-cron-task id (str cron)]) @@ -38,48 +61,86 @@ (l/dbg :hint "register task" :id id :cron (str cron) :status (if updated? "created" "exists"))))) -(defn- lock-scheduled-task! +(defn- lock-scheduled-task [conn id] (let [sql (str "SELECT id FROM scheduled_task " " WHERE id=? FOR UPDATE SKIP LOCKED")] (some? (db/exec-one! conn [sql (d/name id)])))) +(defn submit-cron-job + "Submit the system job for the entry to the `:cron` queue. Uses the + entry id as the job label (stable per entry) and returns the created + job-id. No-overlap pre-check lives in the caller." + [cfg {:keys [job props id]}] + (jobs/submit + cfg + {::jobs/name job + ::jobs/params (or props {}) ;; entries don't carry props; default to empty map + ::jobs/queue :cron + ::jobs/label (name id)})) + (declare ^:private schedule-cron-task) (defn- execute-cron-task - [cfg {:keys [id cron] :as task}] + "Tick thread for one entry: claim the scheduled_task row and submit + the system job; no in-process execution of the handler here." + [cfg {:keys [id cron job] :as entry}] (px/thread {:name (str "penpot/cron-task/" id)} (let [tpoint (ct/tpoint)] (try - (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/exec-one! conn ["SET LOCAL statement_timeout=0;"]) (db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout=0;"]) - (when (lock-scheduled-task! conn id) + + (when (lock-scheduled-task conn id) (db/update! conn :scheduled-task {:cron-expr (str cron) :modified-at (ct/now)} {:id id} {::db/return-keys false}) - (l/dbg :hint "start" :id id) - ((:fn task) task) - (let [elapsed (ct/format-duration (tpoint))] - (l/dbg :hint "end" :id id :elapsed elapsed))))) + + ;; The count query is not locked, so a job could + ;; transition from running to completed between the + ;; count and the submit. This is acceptable: the + ;; next tick will submit if needed. The FOR UPDATE + ;; SKIP LOCKED on the scheduled_task row prevents + ;; race conditions between nodes. + (let [tenant (cf/get :tenant) + row (db/exec-one! conn [sql:count-active-jobs (d/name job) (str id) tenant]) + active (or (get row :n) 0)] + (if (pos? active) + (do + (db/after-commit #(metrics/record-cron cfg :skipped :active)) + (l/dbg :hint "skip" + :reason "scheduling, active instance exists" + :id id :job (d/name job))) + (let [job-id (submit-cron-job cfg entry) + elapsed (ct/format-duration (tpoint))] + (db/after-commit #(metrics/record-cron cfg :submitted :none)) + (l/dbg :hint "submit" + :id id + :job (d/name job) + :job-id (str job-id) + :elapsed elapsed))))))) (catch InterruptedException _ + (metrics/record-cron cfg :error :interrupted) (let [elapsed (ct/format-duration (tpoint))] (l/debug :hint "task interrupted" :id id :elapsed elapsed))) (catch Throwable cause - (let [elapsed (ct/format-duration (tpoint))] - (binding [l/*context* (get-error-context cause task)] + (metrics/record-cron cfg :error :failure) + (let [elapsed (ct/format-duration (tpoint)) + context (assoc (cf/logging-context) :params entry)] + (binding [l/*context* context] (l/err :hint "unhandled exception on running task" :id id :elapsed elapsed :cause cause)))) (finally (when-not (px/interrupted? :current) - (schedule-cron-task cfg task))))))) + (schedule-cron-task cfg entry))))))) (defn- ms-until-valid [cron] @@ -89,9 +150,9 @@ (ct/diff now next))) (defn- schedule-cron-task - [{:keys [::running] :as cfg} {:keys [cron id] :as task}] + [{:keys [::running] :as cfg} {:keys [cron id] :as entry}] (let [ts (ms-until-valid cron) - ft (px/schedule! ts (partial execute-cron-task cfg task))] + ft (px/schedule! ts (partial execute-cron-task cfg entry))] (l/dbg :hint "schedule" :id id :ts (ct/format-duration ts) @@ -106,48 +167,51 @@ [:maybe [:map [:cron [:fn cron/cron-expr?]] - [:task :keyword] + [:job :keyword] [:props {:optional true} :map] [:id {:optional true} :keyword]]]]] - ::wrk/registry - ::db/pool]) + ::jobs/defs + ::db/pool + ::mtx/metrics]) (defmethod ig/assert-key ::wrk/cron [_ params] (assert (sm/check schema:params params))) (defmethod ig/init-key ::wrk/cron - [_ {:keys [::wrk/entries ::wrk/registry ::db/pool] :as cfg}] + [_ {:keys [::wrk/entries ::jobs/defs ::db/pool] :as cfg}] (if (db/read-only? pool) (l/wrn :hint "service not started (db is read-only)") (let [running (atom #{}) - entries (->> entries - (filter some?) - ;; If id is not defined, use the task as id. - (map (fn [{:keys [id task] :as item}] - (if (some? id) - (assoc item :id (d/name id)) - (assoc item :id (d/name task))))) - (map (fn [item] - (update item :task d/name))) - (map (fn [{:keys [task] :as item}] - (let [f (wrk/get-task registry task)] - (when-not f - (ex/raise :type :internal - :code :task-not-found - :hint (str/fmt "task %s not configured" task))) - (-> item - (dissoc :task) - (assoc :fn f)))))) - - cfg (assoc cfg ::entries entries ::running running)] + ;; doall sits outside the threading: it forces the lazy + ;; validation above (unknown job names fail fast here). + ;; NOTE: it must not go inside ->> — the threaded value + ;; would land as doall's count arg and explode. + entries (doall + (->> entries + (filter some?) + ;; If id is not defined, use the job as id. + ;; The job stays a keyword (the canonical job + ;; name): only the id is stringified, because it + ;; becomes the job label and the scheduled_task + ;; row id. + (map (fn [{:keys [id job] :as item}] + (if (some? id) + (assoc item :id (d/name id)) + (assoc item :id (d/name job))))) + (map (fn [item] + ;; fail fast when the entry references + ;; an unknown job name + (jobs/get-job-def defs (:job item)) + item))))] (l/inf :hint "started" :tasks (count entries)) - (db/tx-run! cfg synchronize-cron-entries!) + (let [cfg (assoc cfg ::entries entries ::running running)] + (db/tx-run! cfg synchronize-cron-entries) - (->> (filter some? entries) - (run! (partial schedule-cron-task cfg))) + (->> (filter some? entries) + (run! (partial schedule-cron-task cfg)))) (reify clojure.lang.IDeref @@ -163,4 +227,3 @@ (defmethod ig/halt-key! ::wrk/cron [_ instance] (some-> instance d/close!)) - diff --git a/backend/src/app/worker/dispatcher.clj b/backend/src/app/worker/dispatcher.clj index 8c128e177a..3ade655db3 100644 --- a/backend/src/app/worker/dispatcher.clj +++ b/backend/src/app/worker/dispatcher.clj @@ -7,15 +7,17 @@ (ns app.worker.dispatcher (:require [app.common.data :as d] + [app.common.json :as json] [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.transit :as t] + [app.config :as cf] [app.db :as db] + [app.jobs :as jobs] + [app.jobs.metrics :as metrics] [app.metrics :as mtx] [app.redis :as rds] - [app.worker :as-alias wrk] - [cuerdas.core :as str] + [app.worker :as wrk] [integrant.core :as ig] [promesa.exec :as px]) (:import @@ -26,6 +28,7 @@ (def ^:private schema:dispatcher [:map [::wrk/tenant ::sm/text] + [::lease {:optional true} ::ct/duration] ::mtx/metrics ::db/pool ::rds/client]) @@ -35,154 +38,232 @@ {k (-> (d/without-nils v) (assoc ::timeout (ct/duration "10s")) (assoc ::batch-size 100) - (assoc ::wait-duration (ct/duration "5s")))}) + (assoc ::wait-duration (ct/duration "5s")) + (assoc ::lease (cf/get-jobs-lease)))}) (defmethod ig/assert-key ::wrk/dispatcher [_ cfg] (assert (sm/check schema:dispatcher cfg))) -(def ^:private sql:select-next-tasks - "SELECT id, queue, scheduled_at from task AS t +(def ^:private sql:select-next-jobs + "SELECT id, name, queue, scheduled_at from job AS t WHERE t.scheduled_at <= ?::timestamptz AND (t.status = 'new' OR t.status = 'retry') - AND queue ~~* ?::text + AND t.tenant = ? ORDER BY t.priority DESC, t.scheduled_at LIMIT ? FOR UPDATE SKIP LOCKED") -(def ^:private sql:mark-task-scheduled - "UPDATE task SET status = 'scheduled' +(def ^:private sql:mark-job-scheduled + "UPDATE job SET status = 'scheduled', modified_at = ? WHERE id = ANY(?)") +;; Both sweeps are bulk UPDATEs that run before every dispatch batch, so +;; they are the two queries where a missing tenant filter does the most +;; damage: without it any instance with a worker requeues another +;; instance's scheduled rows and, worse, aborts its running jobs once +;; their lease expires. (def ^:private sql:reschedule-lost - "UPDATE task + "UPDATE job SET status='new', scheduled_at=?::timestamptz FROM (SELECT t.id - FROM task AS t + FROM job AS t WHERE status = 'scheduled' - AND (?::timestamptz - t.scheduled_at) > '5 min'::interval) AS subquery - WHERE task.id=subquery.id -RETURNING task.id, task.queue") + AND t.tenant = ? + AND t.scheduled_at < ?::timestamptz - '5 min'::interval) AS subquery + WHERE job.id=subquery.id +RETURNING job.id, job.queue") -(def ^:private sql:clean-orphan - "UPDATE task - SET status='failed', modified_at=?::timestamptz, - error='orphan with running status' +(def ^:private sql:mark-orphan + "UPDATE job + SET status='aborted', modified_at=?::timestamptz, error=?::jsonb FROM (SELECT t.id - FROM task AS t + FROM job AS t WHERE status = 'running' - AND (?::timestamptz - t.modified_at) > '24 hour'::interval) AS subquery - WHERE task.id=subquery.id -RETURNING task.id, task.queue") + AND t.tenant = ? + AND t.modified_at < ?::timestamptz) AS subquery + WHERE job.id=subquery.id +RETURNING job.id, job.queue, job.name, job.created_at") + +;; the error travels as text and PostgreSQL casts it: a PGobject parameter +;; on a `::jsonb` placeholder is not what the driver expects here. Same +;; for the orphan `end` event payload below. +(def ^:private orphan-error-json + (json/encode jobs/orphan-error)) + +(def ^:private aborted-outcome-json + (json/encode {:outcome "aborted"})) + +(def ^:private sql:insert-orphan-events + "INSERT INTO job_event (job_id, kind, payload) + SELECT unnest(?::uuid[]), 'end', ?::jsonb") + +(defn- encode-payload + [{:keys [id scheduled-at]}] + (json/encode [(str id) (ct/format-inst scheduled-at)])) + +(defn- reschedule-lost-jobs + [{:keys [::db/conn ::timestamp ::wrk/tenant] :as cfg}] + (let [rows (db/exec! conn [sql:reschedule-lost timestamp tenant timestamp] + {:return-keys true})] + (db/after-commit + (fn [] + (doseq [{:keys [id queue]} rows] + (metrics/record-rescheduled cfg queue) + (l/wrn :hint "reschedule" + :id (str id) + :tenant tenant + :queue queue)))))) + +(defn- mark-orphan-jobs + [{:keys [::db/conn ::timestamp ::lease ::wrk/tenant] :as cfg}] + (let [cutoff (ct/minus timestamp (or lease (cf/get-jobs-lease))) + rows (db/exec! conn [sql:mark-orphan timestamp orphan-error-json + tenant cutoff] + {:return-keys true})] + ;; The sweep is the only status change outside `app.jobs`, so it owns + ;; its `end` events too: one bulk insert in the same transaction (never + ;; one insert per orphan) keeps the history consistent with the rows. + ;; No msgbus notification: orphans alert through the error log and the + ;; orphaned counter, like before. + (when (seq rows) + (db/exec-one! conn [sql:insert-orphan-events + (db/create-array conn "uuid" (map :id rows)) + aborted-outcome-json])) + (db/after-commit + (fn [] + (doseq [{:keys [id queue name created-at]} rows] + (metrics/record-orphan cfg queue) + (metrics/record-outcome cfg name queue :aborted) + (when (ct/inst? created-at) + (metrics/record-total cfg name queue :aborted + (- (inst-ms timestamp) (inst-ms created-at)))) + (l/err :hint "marked job as aborted (orphan lease expired)" + :id (str id) + :tenant tenant + :queue queue)))))) + +(defn- get-jobs + [{:keys [::db/conn ::timestamp ::batch-size ::wrk/tenant]}] + (let [result (db/exec! conn [sql:select-next-jobs timestamp tenant batch-size])] + (not-empty result))) + +(defn- mark-as-scheduled + [{:keys [::db/conn]} items] + (let [ids (map :id items) + sql [sql:mark-job-scheduled + (ct/now) + (db/create-array conn "uuid" ids)]] + (db/exec-one! conn sql))) + +(defn- push-jobs + [{:keys [::rds/conn ::wrk/tenant] :as cfg} [queue jobs]] + ;; Mark first, push last: a crash between mark and push leaves a + ;; `scheduled` row that reschedule-lost-jobs re-queues, while the + ;; reverse order leaves a duplicate payload nothing deduplicates. + (mark-as-scheduled cfg jobs) + (let [items (mapv encode-payload jobs) + key (wrk/queue-key tenant queue)] + + (rds/rpush conn key items) + (metrics/record-dispatcher-size cfg queue (count jobs)) + (doseq [{:keys [id name queue]} jobs] + (metrics/record-dispatched cfg name queue 1) + (l/trc :hist "schedule" + :tenant tenant + :queue queue + :job-id (str id))))) + +(defn- run-batch' + [cfg] + (let [cfg (assoc cfg ::timestamp (ct/now)) + tpoint (ct/tpoint)] + ;; Reschedule lost in transit jobs (can happen when + ;; redis server is restarted just after job is pushed) + (reschedule-lost-jobs cfg) + + ;; Mark as aborted all jobs that are still marked as running but + ;; their last modification (heartbeat or progress) is older than + ;; the configured lease + (mark-orphan-jobs cfg) + + ;; Then, schedule the next jobs in queue + (let [result (if-let [jobs (get-jobs cfg)] + (do + (->> (group-by :queue jobs) + (run! (partial push-jobs cfg))) + nil) + + ;; If no jobs found on this batch run, we signal the + ;; run-loop to wait for some time before start running + ;; the next batch iteration + ::wait)] + (db/after-commit + #(metrics/record-dispatcher-batch cfg :dispatch :completed (inst-ms (tpoint)))) + result))) + +(defn- sleep-after-error + [cfg] + (px/sleep (or (::timeout cfg) (ct/duration "10s")))) + +(defn run-batch + "Execute a single dispatch batch: reschedule lost jobs, mark orphans + (lease-based) and claim pending jobs into their Redis queues. Exposed + as a function for testability; the dispatcher thread loops on it." + [cfg] + (let [tpoint (ct/tpoint) + tenant (::wrk/tenant cfg)] + (try + (let [rconn (rds/connect cfg)] + (try + (-> cfg + (assoc ::rds/conn rconn) + (db/tx-run! run-batch')) + (finally + (.close ^AutoCloseable rconn)))) + (catch InterruptedException cause + (throw cause)) + + (catch Exception cause + (cond + (rds/exception? cause) + (do + (metrics/record-dispatcher-batch cfg :redis :failed (inst-ms (tpoint))) + (l/wrn :hint "redis exception (will retry in an instant)" :tenant tenant :cause cause) + (sleep-after-error cfg)) + + (db/sql-exception? cause) + (do + (metrics/record-dispatcher-batch cfg :database :failed (inst-ms (tpoint))) + (l/wrn :hint "database exception (will retry in an instant)" :tenant tenant :cause cause) + (sleep-after-error cfg)) + + :else + (do + (metrics/record-dispatcher-batch cfg :execution :failed (inst-ms (tpoint))) + (l/err :hint "unhandled exception (will retry in an instant)" :tenant tenant :cause cause) + (sleep-after-error cfg))))))) (defmethod ig/init-key ::wrk/dispatcher - [_ {:keys [::db/pool ::wrk/tenant ::batch-size ::timeout] :as cfg}] - (letfn [(reschedule-lost-tasks [{:keys [::db/conn ::timestamp]}] - (doseq [{:keys [id queue]} (db/exec! conn [sql:reschedule-lost timestamp timestamp] - {:return-keys true})] - (l/wrn :hint "reschedule" - :id (str id) - :queue queue))) - - (clean-orphan [{:keys [::db/conn ::timestamp]}] - (doseq [{:keys [id queue]} (db/exec! conn [sql:clean-orphan timestamp timestamp] - {:return-keys true})] - (l/wrn :hint "mark as orphan failed" - :id (str id) - :queue queue))) - - (get-tasks [{:keys [::db/conn ::timestamp] :as cfg}] - (let [prefix (str tenant ":%") - result (db/exec! conn [sql:select-next-tasks timestamp prefix batch-size])] - (not-empty result))) - - (mark-as-scheduled [{:keys [::db/conn]} items] - (let [ids (map :id items) - sql [sql:mark-task-scheduled - (db/create-array conn "uuid" ids)]] - (db/exec-one! conn sql))) - - (push-tasks [{:keys [::rds/conn] :as cfg} [queue tasks]] - (let [items (mapv (juxt :id :scheduled-at) tasks) - key (str/ffmt "penpot.worker.queue:%" queue)] - - (rds/rpush conn key (mapv t/encode-str items)) - (mark-as-scheduled cfg tasks) - - (doseq [{:keys [id queue]} tasks] - (l/trc :hist "schedule" - :id (str id) - :queue queue)))) - - (run-batch' [cfg] - (let [cfg (assoc cfg ::timestamp (ct/now))] - ;; Reschedule lost in transit tasks (can happen when - ;; redis server is restarted just after task is pushed) - (reschedule-lost-tasks cfg) - - ;; Mark as failed all tasks that are still marked as - ;; running but it's been more than 24 hours since its - ;; last modification - (clean-orphan cfg) - - ;; Then, schedule the next tasks in queue - (if-let [tasks (get-tasks cfg)] - (->> (group-by :queue tasks) - (run! (partial push-tasks cfg))) - - ;; If no tasks found on this batch run, we signal the - ;; run-loop to wait for some time before start running - ;; the next batch interation - ::wait))) - - (run-batch [] - (try - (let [rconn (rds/connect cfg)] - (try - (-> cfg - (assoc ::rds/conn rconn) - (db/tx-run! run-batch')) - (finally - (.close ^AutoCloseable rconn)))) - - (catch InterruptedException cause - (throw cause)) - - (catch Exception cause - (cond - (rds/exception? cause) - (do - (l/wrn :hint "redis exception (will retry in an instant)" :cause cause) - (px/sleep timeout)) - - (db/sql-exception? cause) - (do - (l/wrn :hint "database exception (will retry in an instant)" :cause cause) - (px/sleep timeout)) - - :else - (do - (l/err :hint "unhandled exception (will retry in an instant)" :cause cause) - (px/sleep timeout)))))) - - (dispatcher [] - (l/inf :hint "started") + [_ {:keys [::db/pool ::wait-duration ::wrk/tenant] :as cfg}] + (letfn [(dispatcher [] + (l/inf :hint "started" :tenant tenant) (try (loop [] - (let [result (run-batch)] + (let [result (run-batch cfg)] (when (= result ::wait) - (px/sleep (::wait-duration cfg))) + (px/sleep wait-duration)) (recur))) (catch InterruptedException _ - (l/trc :hint "interrupted")) + (l/trc :hint "interrupted" :tenant tenant)) (catch Throwable cause - (l/err :hint "unexpected exception" :cause cause)) + (l/err :hint "unexpected exception" :tenant tenant :cause cause)) (finally - (l/inf :hint "terminated"))))] + (l/inf :hint "terminated" :tenant tenant))))] (if (db/read-only? pool) - (l/wrn :hint "not started (db is read-only)") + (l/wrn :hint "not started (db is read-only)" :tenant tenant) (px/fn->thread dispatcher :name "penpot/worker-dispatcher")))) (defmethod ig/halt-key! ::wrk/dispatcher diff --git a/backend/src/app/worker/runner.clj b/backend/src/app/worker/runner.clj index d6211ca770..f5506f0450 100644 --- a/backend/src/app/worker/runner.clj +++ b/backend/src/app/worker/runner.clj @@ -5,17 +5,21 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.runner - "Async tasks abstraction (impl)." + "Job execution over the unified `job` table (impl)." (:require [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.json :as json] [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.transit :as t] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.jobs :as jobs] + [app.jobs.metrics :as jobs-metrics] [app.metrics :as mtx] + [app.msgbus :as-alias mbus] [app.redis :as rds] [app.worker :as wrk] [cuerdas.core :as str] @@ -26,232 +30,292 @@ (set! *warn-on-reflection* true) -(def schema:task - [:map {:title "Task"} - [:id ::sm/uuid] - [:queue :string] - [:name :string] - [:created-at ::ct/inst] - [:modified-at ::ct/inst] - [:scheduled-at {:optional true} ::ct/inst] - [:completed-at {:optional true} ::ct/inst] - [:error {:optional true} :string] - [:max-retries :int] - [:retry-num :int] - [:priority :int] - [:status [:enum "scheduled" "running" "completed" "new" "retry" "failed"]] - [:label {:optional true} :string] - [:props :map]]) +(defn- claim-job + "Conditional claim: only transition pending jobs (new/scheduled/retry) + to running. Delegates to the shared jobs/claim so internal and + external workers can never drift. A cancelled, aborted or otherwise + terminal job produces + 0 affected rows and is skipped without touching its state + (first-terminal-wins companion). Also predicates the payload + scheduled_at: a row rescheduled after the payload was pushed + (dispatcher re-pushes on reschedule) is never claimed with the stale + payload." + [cfg job-id scheduled-at] + (jobs/claim cfg job-id scheduled-at)) -(def schema:result - [:map {:title "TaskResult"} - [:status [:enum "retry" "failed" "completed"]] - [:error {:optional true} [:fn ex/exception?]] - [:inc-by {:optional true} :int] - [:delay {:optional true} :int]]) +(defn- get-exception-type + "Extract a human-readable exception type for observability." + [error] + (cond + (and (ex/exception? error) (instance? clojure.lang.ExceptionInfo error)) + (or (:type (ex-data error)) :ex-info) -(def valid-task-result? - (sm/validator schema:result)) + (instance? java.util.concurrent.TimeoutException error) + :timeout -(defn- decode-task-row - [{:keys [props] :as row}] - (cond-> row - (db/pgobject? props) - (assoc :props (db/decode-transit-pgobject props)))) + (instance? java.sql.SQLTimeoutException error) + :timeout -(defn get-error-context - [_ item] - (-> (cf/logging-context) - (assoc :params item))) + :else + (keyword (str/lower (.getSimpleName (class error)))))) -(defn- get-task - [{:keys [::db/pool]} task-id] +(defn- error-report + "Build the error stored in `job.error` out of an exception, conforming to + `app.jobs/schema:job-error`. The exception type and its own type/code are + kept as extra details for triage, on top of the three contract keys." + [error] + (let [data (ex-data error) + as-keyword (fn [v] + (cond (keyword? v) v + (string? v) (keyword v)))] + (jobs/check-job-error + {:type (or (as-keyword (:type data)) :internal) + :code (or (as-keyword (:code data)) :failed) + :hint (or (:hint data) + (when (ex/exception? error) (ex-message error)) + (str error)) + :ex-type (get-exception-type error)}))) + +;; A handler result may be the reserved completion envelope, which carries +;; the storage object the job produced. The key is reserved: a handler that +;; wants to return a business map with :resource-id in it cannot. +(defn- completion-envelope? + [result] + (and (map? result) (contains? result :resource-id))) + +(defn- get-job + "Fetch the job row (params kept as raw pgobject; decoded later with the + job-def decoder)." + [cfg job-id] (ex/try! - (some-> (db/get* pool :task {:id task-id}) - (decode-task-row)))) + (some-> (db/get* cfg :job {:id job-id})))) -(defn- run-task - [{:keys [::db/pool ::wrk/registry ::id ::queue] :as cfg} task] +(defn- execute-job + [{:keys [::jobs/defs ::id ::queue ::wrk/tenant] :as cfg} job] (try (l/dbg :hint "start" - :name (:name task) - :task-id (str (:id task)) + :name (:name job) + :job-id (str (:id job)) + :tenant tenant :queue queue :runner-id id - :retry (:retry-num task)) + :retry (:retry-num job)) - ;; Mark task as running - (db/update! pool :task - {:status "running" - :modified-at (ct/now)} - {:id (:id task)} - {::db/return-keys false}) - - (let [tpoint (ct/tpoint) - task-fn (wrk/get-task registry (:name task)) - result (when task-fn (task-fn task)) - elapsed (ct/format-duration (tpoint)) - result (if (valid-task-result? result) - result - {:status "completed"})] - - (when-not task-fn - (l/wrn :hint "no task handler found" :name (:name task))) - - (l/dbg :hint "end" - :name (:name task) - :task-id (str (:id task)) + (if (zero? (claim-job cfg (:id job) (:scheduled-at job))) + (l/wrn :hint "skipping job, not claimable" + :id (str (:id job)) + :name (:name job) + :tenant tenant :queue queue - :runner-id id - :retry (:retry-num task) - :elapsed elapsed) + :status (:status job)) - result) + (do + (let [job-def (jobs/get-job-def defs (:name job)) + _ (jobs-metrics/record-queue-wait + cfg + (::jobs/name job-def) + queue + (- (inst-ms (ct/now)) (inst-ms (:scheduled-at job)))) + params (try + (->> (:params job) + (jobs/decode-params job-def) + (jobs/validate-params job-def)) + (catch Throwable cause + ;; Decode/validation of stored params is pure: any + ;; failure here is permanent (e.g. schema tightened + ;; after submit), never transient. Tag it so the + ;; generic catch below fails fast instead of + ;; burning max-retries. + (throw (ex-info "job params failed validation" + {:type :assertion + :code :data-validation} + cause)))) + handler (::jobs/handler job-def) + context (jobs/make-context job) + tpoint (ct/tpoint) + result (binding [jobs/*job-id* (:id job)] + (try + (handler context params) + (finally + (jobs-metrics/record-execution + cfg + (:name job) + queue + (inst-ms (tpoint))) + (jobs-metrics/record-legacy-execution + cfg + (:name job) + (inst-ms (tpoint))))))] + + (l/dbg :hint "end" + :name (:name job) + :job-id (str (:id job)) + :tenant tenant + :queue queue + :runner-id id + :retry (:retry-num job) + :elapsed (ct/format-duration (tpoint))) + + {:status "completed" + :result result}))) (catch InterruptedException cause (throw cause)) (catch Throwable cause (let [edata (ex-data cause)] - (if (and (< (:retry-num task) - (:max-retries task)) - (= ::retry (:type edata))) + (if (and (< (:retry-num job) + (:max-retries job)) + (= ::wrk/retry (:type edata))) (cond-> {:status "retry" :error cause} (ct/duration? (:delay edata)) - (assoc :delay (:delay edata)) + (assoc :delay-ms (inst-ms (:delay edata))) + (int? (:delay edata)) + (assoc :delay-ms (:delay edata)) - (= ::noop (:strategy edata)) + (= ::wrk/noop (:strategy edata)) (assoc :inc-by 0)) (do - (l/err :hint "unhandled exception on task" - ::l/context (get-error-context cause task) + (l/err :hint "unhandled exception on job" + ::l/context (assoc (cf/logging-context) :params job) + :tenant tenant :cause cause) - (if (>= (:retry-num task) (:max-retries task)) + ;; Unknown job names and invalid params never heal by retrying + ;; (no rolling deploy will register them on this backend), so + ;; they fail fast without burning max-retries or churning + ;; modified_at. + (if (or (>= (:retry-num job) (:max-retries job)) + (contains? #{:no-job-definition :data-validation} + (:code edata))) {:status "failed" :error cause} - {:status "retry" :error cause}))))))) + {:status "retry" :error cause}))))) + (finally + (jobs/cleanup-throttle (:id job))))) -(defn- run-task! - [{:keys [::id ::timeout] :as cfg} task-id scheduled-at] - (loop [task (get-task cfg task-id)] +(defn- run-job + [{:keys [::id ::timeout ::wrk/tenant] :as cfg} job-id scheduled-at] + (loop [job (get-job cfg job-id)] (cond - (nil? task) - (l/wrn :hint "no task found on the database" + (nil? job) + (l/wrn :hint "no job found on the database" :runner-id id - :task-id task-id) + :tenant tenant + :job-id (str job-id)) - (ex/exception? task) - (if (or (db/connection-error? task) - (db/serialization-error? task)) + (ex/exception? job) + (if (or (db/connection-error? job) + (db/serialization-error? job)) (do - (l/wrn :hint "connection error on retrieving task from database (retrying in some instants)" + (l/wrn :hint "connection error on retrieving job from database (retrying in some instants)" :runner-id id - :cause task) + :tenant tenant + :cause job) (px/sleep timeout) - (recur (get-task cfg task-id))) + (recur (get-job cfg job-id))) (do - (l/err :hint "unhandled exception on retrieving task from database (retrying in some instants)" + (l/err :hint "unhandled exception on retrieving job from database (retrying in some instants)" :runner-id id - :cause task) + :tenant tenant + :cause job) (px/sleep timeout) - (recur (get-task cfg task-id)))) + (recur (get-job cfg job-id)))) (not= (inst-ms scheduled-at) - (inst-ms (:scheduled-at task))) - (l/wrn :hint "skiping task, rescheduled" - :task-id task-id + (inst-ms (:scheduled-at job))) + (l/wrn :hint "skipping job, rescheduled" + :job-id (str job-id) :runner-id id - :scheduled-at (ct/format-inst (:scheduled-at task)) + :tenant tenant + :scheduled-at (ct/format-inst (:scheduled-at job)) :expected-scheduled-at (ct/format-inst scheduled-at)) :else - (let [result (run-task cfg task)] + (let [result (execute-job cfg job)] (with-meta result - {::task task}))))) + {::job job}))))) -(defn- run-worker-loop! - [{:keys [::db/pool ::rds/conn ::timeout ::queue] :as cfg}] - (letfn [(handle-task-retry [{:keys [error inc-by delay] :or {inc-by 1 delay 1000} :as result}] - (let [explain (if (ex/exception? error) - (ex-message error) - (str error)) - task (-> result meta ::task) - nretry (+ (:retry-num task) inc-by) - now (ct/now) - delay (->> (iterate #(* % 2) delay) (take nretry) (last))] - (db/update! pool :task - {:error explain - :status "retry" - :modified-at now - :scheduled-at (-> (ct/plus now delay) - (ct/truncate :millisecond)) - :retry-num nretry} - {:id (:id task)}) +(defn- run-worker-loop + [{:keys [::rds/conn ::timeout ::queue ::wrk/tenant] :as cfg}] + (letfn [(handle-job-retry [{:keys [error delay-ms inc-by] :or {inc-by 1 delay-ms 1000} :as result}] + (let [job (-> result meta ::job) + nretry (+ (:retry-num job) inc-by) + now (ct/now) + delay (->> (iterate #(* 2 %) delay-ms) (take (max 1 nretry)) (last))] + (jobs/retry-job cfg + (:id job) + nretry + (-> (ct/plus now (ct/duration {:millis delay})) + (ct/truncate :millisecond)) + (error-report error) + (if (zero? inc-by) :noop :backoff)) nil)) - (handle-task-failure [{:keys [error] :as result}] - (let [task (-> result meta ::task) - explain (ex-message error)] - (db/update! pool :task - {:error explain - :modified-at (ct/now) - :status "failed"} - {:id (:id task)}) + (handle-job-failure [{:keys [error] :as result}] + (let [job (-> result meta ::job)] + (jobs/fail cfg (:id job) (error-report error)) nil)) - (handle-task-completion [result] - (let [task (-> result meta ::task) - now (ct/now)] - (db/update! pool :task - {:completed-at now - :modified-at now - :error nil - :status "completed"} - {:id (:id task)}) + (handle-job-completion [result] + (let [job (-> result meta ::job) + returned (:result result) + envelope? (completion-envelope? returned)] + (jobs/complete cfg + :job-id (:id job) + :result (if envelope? (:result returned) returned) + :resource-id (when envelope? (:resource-id returned))) nil)) (decode-payload [payload] (try - (let [[task-id scheduled-at :as payload] (t/decode-str payload)] - (if (and (uuid? task-id) - (ct/inst? scheduled-at)) - payload - (l/err :hint "received unexpected payload" - :payload payload))) + (let [payload (json/decode payload) + job-id (uuid/parse (first payload)) + sched-at (ct/inst (second payload))] + (if (and (uuid? job-id) + (ct/inst? sched-at)) + [job-id sched-at] + (do + (l/err :hint "received unexpected payload" + :tenant tenant + :payload payload) + nil))) (catch Throwable cause (l/err :hint "unable to decode payload" ::l/context (cf/logging-context) + :tenant tenant :payload payload - :length (alength ^String/1 payload) + :length (count payload) :cause cause)))) (process-result [{:keys [status] :as result}] (ex/try! (case status - "retry" (handle-task-retry result) - "failed" (handle-task-failure result) - "completed" (handle-task-completion result) + "retry" (handle-job-retry result) + "failed" (handle-job-failure result) + "completed" (handle-job-completion result) (throw (IllegalArgumentException. (str "invalid status received: '" status "'")))))) - (run-task-loop [[task-id scheduled-at]] - (loop [result (run-task! cfg task-id scheduled-at)] + (run-job-loop [[job-id scheduled-at]] + (loop [result (run-job cfg job-id scheduled-at)] (when-let [cause (some-> result process-result)] (if (or (db/connection-error? cause) (db/serialization-error? cause)) (do - (l/wrn :hint "database exeption on processing task result (retrying in some instants)" + (l/wrn :hint "database exeption on processing job result (retrying in some instants)" + :tenant tenant :cause cause) (px/sleep timeout) (recur result)) - (l/err :hint "unhandled exception on processing task result" + (l/err :hint "unhandled exception on processing job result" ::l/context (cf/logging-context) + :tenant tenant :cause cause)))))] (try - (let [key (str/ffmt "penpot.worker.queue:%" queue) + (let [key (wrk/queue-key tenant queue) [_ payload] (rds/blpop conn [key] timeout)] (some-> payload decode-payload - run-task-loop)) + run-job-loop)) (catch InterruptedException cause (throw cause)) @@ -261,46 +325,50 @@ (do (l/err :hint "redis pop operation timeout, consider increasing redis timeout (will retry in some instants)" ::l/context (cf/logging-context) + :tenant tenant :timeout timeout :cause cause) (px/sleep timeout)) (l/err :hint "unhandled exception" ::l/context (cf/logging-context) + :tenant tenant :cause cause)))))) -(defn- start-thread! +(defn- start-thread [{:keys [::id ::queue ::wrk/tenant] :as cfg}] (px/thread {:name (str "penpot/job-runner/" id)} - (l/inf :hint "started" :id id :queue queue) + (l/inf :hint "started" :id id :tenant tenant :queue queue) (let [rconn (rds/connect cfg)] (try (loop [cfg (-> cfg (assoc ::rds/conn rconn) - (assoc ::queue (str/ffmt "%:%" tenant queue)) (assoc ::timeout (ct/duration "5s")))] (when (px/interrupted?) (throw (InterruptedException. "interrupted"))) - (run-worker-loop! cfg) + (run-worker-loop cfg) (recur cfg)) (catch InterruptedException _ (l/dbg :hint "interrupted" :id id + :tenant tenant :queue queue)) (catch Throwable cause (l/err :hint "unexpected exception" ::l/context (cf/logging-context) :id id + :tenant tenant :queue queue :cause cause)) (finally (.close ^AutoCloseable rconn) (l/inf :hint "terminated" :id id + :tenant tenant :queue queue)))))) (def ^:private schema:params @@ -308,10 +376,12 @@ [::wrk/parallelism {:optional true} ::sm/int] [::wrk/queue :keyword] [::wrk/tenant ::sm/text] - ::wrk/registry + ::jobs/defs ::mtx/metrics ::db/pool - ::rds/client]) + ::rds/client + ;; job events of a profile job are published on the profile topic + ::mbus/msgbus]) (defmethod ig/assert-key ::wrk/runner [_ params] @@ -322,15 +392,15 @@ {k (merge {::wrk/parallelism 1} (d/without-nils v))}) (defmethod ig/init-key ::wrk/runner - [_ {:keys [::db/pool ::wrk/queue ::wrk/parallelism] :as cfg}] + [_ {:keys [::db/pool ::wrk/queue ::wrk/parallelism ::wrk/tenant] :as cfg}] (let [queue (d/name queue) cfg (assoc cfg ::queue queue)] (if (db/read-only? pool) - (l/wrn :hint "not started (db is read-only)" :queue queue :parallelism parallelism) + (l/wrn :hint "not started (db is read-only)" :tenant tenant :queue queue :parallelism parallelism) (doall (->> (range parallelism) (map #(assoc cfg ::id (str queue "/" %))) - (map start-thread!)))))) + (map start-thread)))))) (defmethod ig/halt-key! ::wrk/runner [_ threads] diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 715def2ad2..a3931e60aa 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -180,6 +180,7 @@ (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (v3/import-files!)) file-id (first (:file-ids result)) @@ -213,6 +214,7 @@ result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input input) (v3/import-files!))] (bfc/get-file th/*system* (first (:file-ids result))))) @@ -267,6 +269,7 @@ (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (v3/import-files!))] (t/is (map? result)) @@ -300,6 +303,7 @@ (let [result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (v3/import-files!)) imported (bfc/get-file th/*system* (first (:file-ids result)))] @@ -1962,6 +1966,7 @@ (let [cfg (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (assoc ::bfc/import-max-zip-entries 1)) out (try @@ -2018,6 +2023,7 @@ (let [cfg (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (assoc ::bfc/import-max-binary-entry-size 1)) out (try @@ -2106,6 +2112,7 @@ (let [cfg (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input bombed)) out (try-import-files! cfg)] (t/is (= :validation (:type out))) @@ -2147,6 +2154,7 @@ (let [cfg (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (assoc ::bfc/import-max-text-total-size 100)) out (try-import-files! cfg)] @@ -2194,6 +2202,7 @@ (let [cfg (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input exported) (assoc ::bfc/import-max-text-total-size budget)) out (try-import-files! cfg)] @@ -2381,6 +2390,7 @@ (let [result (:file-ids (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (v3/import-files!))) files (map #(bfc/get-file th/*system* %) result) @@ -2459,6 +2469,7 @@ (let [result (:file-ids (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input output) (v3/import-files!))) mobjs (db/query th/*system* :file-media-object diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index 131370f603..863be00a5e 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -38,3 +38,24 @@ (t/testing "a leading slash would drop the subpath, so it fails fast" (t/is (thrown? AssertionError (cf/join-uri "https://example.com/penpot" "/assets/by-id/123"))))) + +(t/deftest tenant-allows-only-alphanumeric + (t/testing "default and plain alphanumeric tenants validate" + (doseq [tenant ["default" "acme42" "my-company"]] + (t/is (cf/validate-config (assoc cf/config :tenant tenant))))) + (t/testing "LIKE wildcards and separators are rejected" + (doseq [tenant ["my_tenant" "a%b" "a:b" "a b" "" "acme.example.com"]] + (t/is (not (cf/validate-config (assoc cf/config :tenant tenant))))))) + +(t/deftest worker-parallelism-keys + (t/testing "schema decodes all runner parallelism keys (env strings to int)" + (let [decoded (cf/decode-config {:worker-default-parallelism "4" + :worker-webhook-parallelism "2" + :worker-cron-parallelism "3"})] + (t/is (= 4 (:worker-default-parallelism decoded))) + (t/is (= 2 (:worker-webhook-parallelism decoded))) + (t/is (= 3 (:worker-cron-parallelism decoded))))) + (t/testing "lookups resolve configured values (unnamespaced keys, as main uses)" + (with-redefs [cf/config (assoc cf/config + :worker-default-parallelism 4)] + (t/is (= 4 (cf/get :worker-default-parallelism 1)))))) diff --git a/backend/test/backend_tests/db_transaction_test.clj b/backend/test/backend_tests/db_transaction_test.clj new file mode 100644 index 0000000000..b89c84be81 --- /dev/null +++ b/backend/test/backend_tests/db_transaction_test.clj @@ -0,0 +1,80 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.db-transaction-test + (:require + [app.common.uuid :as uuid] + [app.db :as db] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) + +(t/deftest after-commit-runs-after-a-successful-transaction + (let [events (atom [])] + (db/tx-run! th/*pool* + (fn [_] + (db/after-commit #(swap! events conj :committed)))) + (t/is (= [:committed] @events)))) + +(t/deftest after-commit-is-discarded-on-rollback + (let [events (atom [])] + (t/is (thrown? Exception + (db/tx-run! th/*pool* + (fn [_] + (db/after-commit #(swap! events conj :committed)) + (throw (ex-info "rollback" {})))))) + (t/is (empty? @events)))) + +(t/deftest nested-transactions-share-the-outer-commit + (let [events (atom [])] + (db/tx-run! th/*pool* + (fn [cfg] + (db/after-commit #(swap! events conj :outer)) + (db/tx-run! cfg + (fn [_] + (db/after-commit #(swap! events conj :inner)))))) + (t/is (= [:outer :inner] @events)))) + +(t/deftest after-commit-runs-immediately-outside-a-transaction + (let [events (atom [])] + (db/after-commit #(swap! events conj :immediate)) + (t/is (= [:immediate] @events)))) + +(t/deftest a-rollback-only-transaction-runs-no-callbacks + (let [events (atom [])] + (db/tx-run! {::db/pool th/*pool* + ::db/rollback true} + (fn [_] + (db/after-commit #(swap! events conj :committed)))) + (t/is (= [] @events) + "the transaction rolled back, so there is no commit to follow"))) + +(t/deftest an-independent-transaction-owns-its-callbacks + (let [events (atom []) + job-id (uuid/next)] + (try + (try + (db/tx-run! th/*pool* + (fn [_] + (db/after-commit #(swap! events conj :outer)) + ;; a different connection: its own unit of work, so + ;; it commits on its own and owns its own callbacks + (db/tx-run! th/*pool* + (fn [cfg] + (db/after-commit #(swap! events conj :inner)) + (db/exec-one! cfg ["INSERT INTO job (id, name, tenant, queue) + VALUES (?, ?, ?, ?)" + job-id "test" "acme" "default"]))) + (throw (ex-info "rollback" {})))) + (catch Exception _ nil)) + (t/testing "the row written by the inner transaction survives the caller rollback" + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job WHERE id = ?" + job-id]))))) + (t/testing "its callback ran with its own commit, the caller one was dropped" + (t/is (= [:inner] @events))) + (finally + (th/db-force-delete :job {:id job-id}))))) diff --git a/backend/test/backend_tests/demo_test.clj b/backend/test/backend_tests/demo_test.clj index 66b8c04ddf..5e9ba555b1 100644 --- a/backend/test/backend_tests/demo_test.clj +++ b/backend/test/backend_tests/demo_test.clj @@ -8,9 +8,9 @@ (:require [app.common.time :as ct] [app.db :as db] + [app.jobs :as jobs] [app.rpc.commands.profile :as profile] [app.tasks.demo-purge :as demo-purge] - [app.worker :as wrk] [backend-tests.helpers :as th] [clojure.test :as t] [integrant.core :as ig])) @@ -34,14 +34,44 @@ (t/deftest demo-purge-handler-submits-delete-object (let [profile (th/create-profile* 996 {:is-demo true}) - handler (ig/init-key :app.tasks.demo-purge/handler - {::db/pool th/*pool*}) + handler (ig/init-key :app.tasks.demo-purge/job-def + {::db/pool th/*pool* + ::jobs/defs {:delete-object {::jobs/name :delete-object + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (fn [_] true)}}}) submitted (atom nil)] - (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}] - (reset! submitted {:task task :params params}))] - (handler {:props {:profile-id (:id profile) - :deleted-at (ct/now)}})) - (t/is (= :delete-object (:task @submitted))) + (with-redefs [jobs/submit (fn [cfg options] + (reset! submitted + {:name (get options ::jobs/name) + :params (get options ::jobs/params)}))] + ((::jobs/handler handler) nil {:profile-id (:id profile)})) + (t/is (= :delete-object (:name @submitted))) (t/is (= :profile (:object (:params @submitted)))) (t/is (= (:id profile) (:id (:params @submitted)))) (t/is (some? (:deleted-at (:params @submitted)))))) + +(t/deftest job-handler-uses-its-closed-cfg + "The handler is [context params]: the job-def keeps the dependencies it + closed over at init time, so it still reaches the database through its + own pool and needs nothing from the caller." + (let [profile (th/create-profile* 997 {:is-demo true}) + handler (ig/init-key :app.tasks.demo-purge/job-def + {::db/pool th/*pool* + ::jobs/defs {:delete-object {::jobs/name :delete-object + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (fn [_] true)}}}) + submitted (atom nil)] + (with-redefs [jobs/submit (fn [cfg options] + (reset! submitted + {:pool (::db/pool cfg) + :params (get options ::jobs/params)}))] + ((::jobs/handler handler) nil {:profile-id (:id profile)}) + + (t/testing "the closed pool is the one the handler submitted with" + (t/is (db/pool? (:pool @submitted))) + (t/is (identical? th/*pool* (:pool @submitted))) + (t/is (= (:id profile) (:id (:params @submitted)))))))) diff --git a/backend/test/backend_tests/email_sending_test.clj b/backend/test/backend_tests/email_sending_test.clj index dace75231e..b49f675a56 100644 --- a/backend/test/backend_tests/email_sending_test.clj +++ b/backend/test/backend_tests/email_sending_test.clj @@ -9,9 +9,12 @@ [app.config :as cf] [app.db :as db] [app.email :as emails] + [app.jobs :as jobs] + [app.metrics :as-alias mtx] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str] + [integrant.core :as ig] [promesa.core :as p])) (t/use-fixtures :once th/state-init) @@ -118,3 +121,24 @@ (t/is (not (str/includes? (email-text-body result) "Hi ,"))) (t/is (str/includes? (get-in result [:body "text/html"]) "Hi,")) (t/is (not (str/includes? (get-in result [:body "text/html"]) "Hi ,"))))) + +(t/deftest send-with-reuse-conn-and-no-conn-raises + (t/testing "reuse-conn without a caller connection fails fast" + (t/is (thrown-with-msg? clojure.lang.ExceptionInfo #"reuse-conn" + (emails/send {} + {::emails/reuse-conn true + ::emails/factory (fn [_] {:to "a@example.com"})}))))) + +(t/deftest send-with-reuse-conn-joins-caller-transaction + (let [job-def (ig/init-key ::emails/job-def + {::emails/sendmail (fn [_] nil)}) + defs {:sendmail job-def} + email {:to "a@example.com" :subject "hi" :body "hello"} + job-id (db/tx-run! {::db/pool th/*pool*} + (fn [cfg] + (emails/send (assoc cfg + ::jobs/defs defs + ::mtx/metrics (get th/*system* :app.metrics/metrics)) + (assoc email ::emails/reuse-conn true))))] + (t/testing "the email job is submitted through the caller conn" + (t/is (= "sendmail" (:name (th/db-get :job {:id job-id}))))))) diff --git a/backend/test/backend_tests/file_gc_scheduler_test.clj b/backend/test/backend_tests/file_gc_scheduler_test.clj new file mode 100644 index 0000000000..0edd217acb --- /dev/null +++ b/backend/test/backend_tests/file_gc_scheduler_test.clj @@ -0,0 +1,28 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.file-gc-scheduler-test + (:require + [app.jobs :as jobs] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest min-age-validation + (let [cfg th/*system*] + (t/testing "int millis, text and absence validate" + (t/is (uuid? (jobs/submit cfg {::jobs/name :file-gc-scheduler + ::jobs/params {:min-age 3600000}}))) + (t/is (uuid? (jobs/submit cfg {::jobs/name :file-gc-scheduler + ::jobs/params {:min-age "1h"}}))) + (t/is (uuid? (jobs/submit cfg {::jobs/name :file-gc-scheduler + ::jobs/params {}})))) + (t/testing "garbage fails at submit instead of burning retries" + (t/is (thrown-with-msg? Exception #"check error" + (jobs/submit cfg {::jobs/name :file-gc-scheduler + ::jobs/params {:min-age {:bogus true}}})))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 1ee446af6a..13ac46cf66 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -20,6 +20,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.jobs :as jobs] [app.main :as main] [app.media] [app.media :as-alias mtx] @@ -35,7 +36,6 @@ [app.rpc.helpers :as rph] [app.util.blob :as blob] [app.util.services :as sv] - [app.worker :as wrk] [app.worker.runner] [clojure.java.io :as io] [clojure.spec.alpha :as s] @@ -134,7 +134,8 @@ :app.worker/cron :app.worker/dispatcher [:app.main/default :app.worker/runner] - [:app.main/webhook :app.worker/runner])) + [:app.main/webhook :app.worker/runner] + [:app.main/cron :app.worker/runner])) _ (ig/load-namespaces system) system (-> (ig/expand system) (ig/init))] (try @@ -238,8 +239,10 @@ (defn mark-file-deleted* ([params] (mark-file-deleted* *system* params)) - ([conn {:keys [id] :as params}] - (#'files/mark-file-deleted conn {} id))) + ([system {:keys [id] :as params}] + (db/tx-run! system + (fn [cfg] + (#'files/mark-file-deleted cfg {} id))))) (defn create-team* ([i params] (create-team* *system* i params)) @@ -494,28 +497,37 @@ (try-on! (method-fn params))))) (defn run-task! + "Execute a job handler directly (in-process, no row): the handler gets a + nil context, because there is no job to describe." ([name] (run-task! name {})) ([name params] - (wrk/invoke! (-> *system* - (assoc ::wrk/task name) - (assoc ::wrk/params params))))) + (jobs/invoke (-> *system* + (assoc ::jobs/name name) + (assoc ::jobs/params params))))) -(def sql:pending-tasks - "select t.* from task as t - where t.status = 'new' - order by t.priority desc, t.scheduled_at") +(def sql:pending-jobs + "select * from job + where status = 'new' + order by priority desc, scheduled_at") -(defn run-pending-tasks! +(defn run-pending-jobs + "Execute the pending (status='new') `job` rows in-process (simulating + the dispatcher + runner for the tests). Each row gets its context and its + job-id, so heartbeats and progress reach it exactly like in the runner. + Does not touch the row status; only the handler side effects matter." [] - (db/tx-run! *system* (fn [{:keys [::db/conn] :as cfg}] - (let [tasks (->> (db/exec! conn [sql:pending-tasks]) - (map #'app.worker.runner/decode-task-row))] - (doseq [task tasks] - (let [cfg (-> cfg - (assoc :app.worker.runner/queue (:queue task)) - (assoc :app.worker.runner/id 0))] - (#'app.worker.runner/run-task cfg task))))))) + (db/tx-run! *system* + (fn [{:keys [::db/conn]}] + (let [jobs-rows (db/exec! conn [sql:pending-jobs])] + (doseq [row jobs-rows] + (jobs/invoke (-> *system* + ;; the row stores a string; invoke + ;; takes the canonical keyword + (assoc ::jobs/name (keyword (:name row))) + (assoc ::jobs/params (:params row)) + (assoc ::jobs/context (jobs/make-context row)) + (assoc ::jobs/job-id (:id row))))))))) ;; --- UTILS @@ -627,6 +639,17 @@ [& params] (apply db/delete! *pool* params)) +(defn db-force-delete + "Deletes rows whose table is guarded by `raise_deletion_protection`, + inside a transaction that disables the guard. Test-only escape hatch: + production delete paths disable protection in their own transaction + (see `app.jobs.gc`)." + [& params] + (db/tx-run! *pool* + (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) + (apply db/delete! conn params)))) + (defn db-query [& params] (apply db/query *pool* params)) diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index 1651d92e0a..cc644a0221 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -412,25 +412,25 @@ :auth-token-cookie-max-age idle :auth-token-cookie-max-age-absolute absolute default)))] - (let [expanded (ig/expand-key :app.http.session.tasks/gc {})] + (let [expanded (ig/expand-key :app.http.session/job-def {})] (t/is (= idle - (get-in expanded [:app.http.session.tasks/gc + (get-in expanded [:app.http.session/job-def :app.http.session.tasks/max-age])) "task max-age should carry the configured idle window") (t/is (= absolute - (get-in expanded [:app.http.session.tasks/gc + (get-in expanded [:app.http.session/job-def :app.http.session.tasks/max-age-absolute])) "task max-age-absolute should carry the configured absolute cap"))) (with-redefs [cf/get (fn ([_k] nil) ([_k default] default))] - (let [expanded (ig/expand-key :app.http.session.tasks/gc {})] + (let [expanded (ig/expand-key :app.http.session/job-def {})] (t/is (= session/default-cookie-max-age - (get-in expanded [:app.http.session.tasks/gc + (get-in expanded [:app.http.session/job-def :app.http.session.tasks/max-age])) "task max-age should fall back to the default idle window") (t/is (= session/default-cookie-max-age-absolute - (get-in expanded [:app.http.session.tasks/gc + (get-in expanded [:app.http.session/job-def :app.http.session.tasks/max-age-absolute])) "task max-age-absolute should fall back to the default absolute cap"))))) @@ -439,15 +439,15 @@ params {:app.db/pool pool :app.http.session.tasks/max-age (ct/duration {:days 7}) :app.http.session.tasks/max-age-absolute (ct/duration {:days 30})}] - (t/is (nil? (ig/assert-key :app.http.session.tasks/gc params)) + (t/is (nil? (ig/assert-key :app.http.session/job-def params)) "absolute cap above the idle window should pass") - (t/is (nil? (ig/assert-key :app.http.session.tasks/gc + (t/is (nil? (ig/assert-key :app.http.session/job-def (assoc params :app.http.session.tasks/max-age-absolute (ct/duration {:days 7})))) "absolute cap equal to the idle window should pass") (t/is (thrown? IllegalArgumentException - (ig/assert-key :app.http.session.tasks/gc + (ig/assert-key :app.http.session/job-def (assoc params :app.http.session.tasks/max-age-absolute (ct/duration {:days 3})))) diff --git a/backend/test/backend_tests/jobs_gc_test.clj b/backend/test/backend_tests/jobs_gc_test.clj new file mode 100644 index 0000000000..ca4a8d170e --- /dev/null +++ b/backend/test/backend_tests/jobs_gc_test.clj @@ -0,0 +1,361 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.jobs-gc-test + (:require + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.gc :as gc] + [app.main :as main] + [app.metrics :as-alias mtx] + [app.metrics.definition :as-alias mdef] + [app.storage :as sto] + [backend-tests.helpers :as th] + [backend-tests.storage-test :refer [configure-storage-backend]] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial + th/database-reset + th/clean-storage)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-metrics [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:jobs-gc-rows])})) + +(defn- counter-value [metrics labels] + (let [collector (mtx/get-collector metrics :jobs-gc-rows) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(defn- mk-storage-object + [] + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend))] + (sto/put-object! storage {::sto/content (sto/content "content") + :content-type "text/plain"}))) + +(defn- mk-job + [{:keys [status profile-id resource-id expires-at modified-at] + :or {status "new"}}] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status status + :profile-id profile-id + :resource-id resource-id + :expires-at expires-at + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (or modified-at (ct/now))}) + id)) + +(t/deftest gc-records-deleted-rows + (let [metrics (make-metrics) + cfg {::db/pool th/*pool* + ::mtx/metrics metrics}] + (th/db-insert! :job {:id (uuid/next) + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "completed" + :expires-at (ct/in-past {:minutes 5}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + (gc/execute-jobs-gc cfg {}) + (t/is (= 1.0 (counter-value metrics ["expired" "deleted"]))))) + +(t/deftest job-rows-are-protected-against-unintended-deletes + (let [job-id (mk-job {:expires-at (ct/in-past {:minutes 5})})] + (t/testing "a stray DELETE is blocked by the deletion protection trigger" + (t/is (thrown-with-msg? Exception #"unable to proceed to delete row" + (th/db-exec! ["DELETE FROM job WHERE id = ?" job-id]))) + (t/is (some? (th/db-get :job {:id job-id} :id :status)))) + (t/testing "the jobs GC is the intended deleter and disables the guard" + (th/run-task! :jobs-gc {}) + (t/is (nil? (th/db-get :job {:id job-id} :id :status)))))) + +(t/deftest gc-deletes-expired-jobs-and-touches-their-resources + (let [old-object (mk-storage-object) + live-object (mk-storage-object) + expired-id (uuid/next) + live-id (uuid/next)] + (th/db-insert! :job {:id expired-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "completed" + :resource-id (:id old-object) + :expires-at (ct/in-past {:minutes 5}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + (th/db-insert! :job {:id live-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "running" + :resource-id (:id live-object) + :expires-at (ct/in-future {:minutes 5}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (th/run-task! :jobs-gc {}) + + (t/is (nil? (th/db-get :job {:id expired-id} :id :status))) + (t/is (some? (th/db-get :job {:id live-id} :id :status))) + (t/is (some? (:touched-at (th/db-get :storage-object {:id (:id old-object)} + :id :touched-at)))) + (t/is (nil? (:touched-at (th/db-get :storage-object {:id (:id live-object)} + :id :touched-at))))) + + ;; a row without resource is deleted without touching anything + (let [expired-id (uuid/next)] + (th/db-insert! :job {:id expired-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "new" + :expires-at (ct/in-past {:minutes 5}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + (th/run-task! :jobs-gc {}) + (t/is (nil? (th/db-get :job {:id expired-id} :id :status))))) + +(t/deftest gc-params-accept-string-min-age + (t/testing "duration strings validate like durations and millis" + (t/is ((sm/validator gc/schema:jobs-gc-params) {:min-age "1h"})) + (t/is ((sm/validator gc/schema:jobs-gc-params) {:min-age 3600000})) + (t/is ((sm/validator gc/schema:jobs-gc-params) {:min-age (ct/duration {:hours 1})}))) + (t/testing "string min-age runs like a duration" + (t/is (map? (th/run-task! :jobs-gc {:min-age "1h"}))))) + +(t/deftest submit-normalizes-durations-to-millis + (let [defs {:jobs-gc (ig/init-key ::gc/job-def {})} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics (get th/*system* :app.metrics/metrics)} + job-id (jobs/submit cfg {::jobs/name :jobs-gc + ::jobs/params {:min-age (ct/duration {:hours 1})}})] + (t/testing "a Duration object does not reach JSON encoding" + (t/is (= 3600000 (:min-age (:params (jobs/get-job cfg job-id)))))))) + +(t/deftest gc-retention-deletes-old-internal-terminal-rows + (let [profile (th/create-profile* 1 {}) + ;; old internal terminal row -> deleted + old-id (uuid/next)] + (th/db-insert! :job {:id old-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "completed" + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/in-past {:days 10})}) + ;; recent internal terminal row: inside the retention window, survives + (let [recent-id (uuid/next)] + (th/db-insert! :job {:id recent-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "completed" + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + ;; non-terminal internal row: never swept by retention, survives + (let [running-id (uuid/next)] + (th/db-insert! :job {:id running-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "running" + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/in-past {:days 10})}) + ;; old USER terminal row (profile set): retention does not sweep + ;; the user ledger, survives + (let [user-id (uuid/next)] + (th/db-insert! :job {:id user-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "completed" + :profile-id (:id profile) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/in-past {:days 10})}) + (th/run-task! :jobs-gc {}) + + (t/is (nil? (th/db-get :job {:id old-id} :id :status))) + (t/is (= 3 (count (th/db-query :job {:queue "default"})))) + (t/is (some? (th/db-get :job {:id recent-id} :id :status))) + (t/is (some? (th/db-get :job {:id running-id} :id :status))) + (t/is (some? (th/db-get :job {:id user-id} :id :status)))))))) + +(t/deftest gc-retention-deletes-old-aborted-rows + "Aborted jobs are terminal like failed/cancelled: old internal aborted + rows are swept by retention, recent ones survive." + (let [old-id (uuid/next) + recent-id (uuid/next)] + (doseq [[id modified-at] [[old-id (ct/in-past {:days 10})] + [recent-id (ct/now)]]] + (th/db-insert! :job {:id id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "aborted" + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at modified-at})) + (th/run-task! :jobs-gc {}) + (t/is (nil? (th/db-get :job {:id old-id} :id :status))) + (t/is (some? (th/db-get :job {:id recent-id} :id :status))))) + +(t/deftest gc-retention-touches-resources-of-retained-rows + (let [object (mk-storage-object) + job-id (uuid/next)] + (th/db-insert! :job {:id job-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "failed" + :resource-id (:id object) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/in-past {:days 10})}) + (th/run-task! :jobs-gc {}) + (t/is (nil? (th/db-get :job {:id job-id} :id :status))) + (t/is (some? (:touched-at (th/db-get :storage-object {:id (:id object)} + :id :touched-at)))))) + +(t/deftest gc-deletes-expired-jobs-in-bounded-batches + (let [total 2500] + (th/db-exec! [(str "INSERT INTO job (name, tenant, queue, status, expires_at) " + "SELECT 'test-job', '" (cf/get :tenant) "', 'default', 'completed', " + "now() - interval '1 hour' " + "FROM generate_series(1, " total ")")]) + ;; spread a few resources across batches so every batch touches + (let [objects (repeatedly 3 mk-storage-object)] + (doseq [[object n] (map vector objects (range))] + (th/db-exec! ["UPDATE job SET resource_id = ? + WHERE id = (SELECT id FROM job OFFSET ? LIMIT 1)" + (:id object) (* n 1000)])) + (let [{:keys [deleted-expired touched-expired]} (th/run-task! :jobs-gc {})] + (t/is (= total deleted-expired)) + (t/is (= (count objects) touched-expired)) + (t/is (= 0 (count (th/db-query :job {:queue "default"})))) + (doseq [object objects] + (t/is (some? (:touched-at (th/db-get :storage-object {:id (:id object)} + :id :touched-at))))))))) + +(t/deftest gc-commits-each-batch-separately + (let [total 2500 + calls (atom 0) + orig @#'gc/touch-resources] + (th/db-exec! [(str "INSERT INTO job (name, tenant, queue, status, expires_at) " + "SELECT 'test-job', '" (cf/get :tenant) "', 'default', 'completed', " + "now() - interval '1 hour' " + "FROM generate_series(1, " total ")")]) + ;; blow up on the 3rd batch: earlier batches must stay committed + (alter-var-root #'gc/touch-resources + (constantly (fn [conn ids] + (when (= 3 (swap! calls inc)) + (throw (ex-info "boom" {}))) + (orig conn ids)))) + (try + (t/is (thrown? Exception (th/run-task! :jobs-gc {}))) + (t/is (= 500 (count (th/db-query :job {:queue "default"})))) + (finally + (alter-var-root #'gc/touch-resources (constantly orig)))) + ;; the next tick completes the sweep + (let [{:keys [deleted-expired]} (th/run-task! :jobs-gc {})] + (t/is (= 500 deleted-expired)) + (t/is (zero? (count (th/db-query :job {:queue "default"}))))))) + +(t/deftest gc-expiration-skips-active-rows + (let [running-id (uuid/next) + retry-id (uuid/next)] + (doseq [[id status] [[running-id "running"] [retry-id "retry"]]] + (th/db-insert! :job {:id id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status status + :expires-at (ct/in-past {:minutes 5}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)})) + (th/run-task! :jobs-gc {}) + (t/testing "active rows survive expiration" + (t/is (some? (th/db-get :job {:id running-id} :id :status))) + (t/is (some? (th/db-get :job {:id retry-id} :id :status)))))) diff --git a/backend/test/backend_tests/jobs_metrics_test.clj b/backend/test/backend_tests/jobs_metrics_test.clj new file mode 100644 index 0000000000..af43e856f3 --- /dev/null +++ b/backend/test/backend_tests/jobs_metrics_test.clj @@ -0,0 +1,307 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.jobs-metrics-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.metrics :as jobs-metrics] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [integrant.core :as ig]) + (:import + io.prometheus.client.Collector$MetricFamilySamples + io.prometheus.client.Collector$MetricFamilySamples$Sample + io.prometheus.client.CollectorRegistry + io.prometheus.client.Counter + io.prometheus.client.Counter$Child + io.prometheus.client.Gauge + io.prometheus.client.Gauge$Child)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private metric-ids + [:jobs-submitted + :jobs-dispatched + :jobs-completed + :jobs-retries + :jobs-orphaned + :jobs-rescheduled + :jobs-queue-wait-timing + :jobs-execution-timing + :jobs-total-timing + :jobs-dispatcher-timing + :jobs-dispatcher-batch-size + :jobs-backlog + :jobs-oldest-pending-age + :jobs-gc-rows + :jobs-gc-timing + :jobs-cron-total + :jobs-requests-total + :jobs-request-timing + :jobs-events-total]) + +;; The legacy histogram is registered too, so it can be asserted, but it is +;; not part of the `penpot_jobs_` contract the test above checks. +(def ^:private registry-ids (conj metric-ids :tasks-timing)) + +(defn- make-metrics [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics registry-ids)})) + +(defn- metrics-cfg + "The cfg the `record-*` functions resolve the instance from. They take + a cfg, not an instance, so this is what a caller hands them." + [metrics] + {::mtx/metrics metrics}) + +(defn- counter-value [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(defn- histogram-count + "How many observations a histogram has recorded for an exact set of + labels, read off the registry so the label set itself is checked." + [metrics id labels] + (let [definition (get main/default-metrics id)] + (->> (enumeration-seq + (.metricFamilySamples ^CollectorRegistry (mtx/get-registry metrics))) + (filter (fn [^Collector$MetricFamilySamples family] + (= (.-name family) (::mdef/name definition)))) + (mapcat (fn [^Collector$MetricFamilySamples family] + (.samples family))) + (filter (fn [^Collector$MetricFamilySamples$Sample sample] + (and (str/ends-with? (.-name sample) "_count") + (= labels (vec (.-labelValues sample)))))) + (map (fn [^Collector$MetricFamilySamples$Sample sample] (.-value sample))) + ;; 0.0 and not 0: an empty sum must compare equal to the 0.0 the + ;; assertions use, and (= 0 0.0) is false in Clojure + (reduce + 0.0)))) + +(defn- gauge-value [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Gauge instance (into-array String labels))] + (.get ^Gauge$Child child))) + +(t/deftest default-metrics-cover-the-jobs-observability-contract + (doseq [id metric-ids] + (let [definition (get main/default-metrics id)] + (t/is (some? definition) id) + (t/is (re-find #"^penpot_jobs_" (::mdef/name definition)) id))) + (t/is (= ["name" "queue" "outcome"] + (::mdef/labels (:jobs-completed main/default-metrics)))) + (t/is (= ["status"] + (::mdef/labels (:jobs-backlog main/default-metrics))))) + +(t/deftest submit-metric-is-not-recorded-when-the-outer-transaction-rolls-back + (let [metrics (make-metrics) + defs {:echo {::jobs/name :echo + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics metrics}] + (t/is (thrown? Exception + (db/tx-run! cfg + (fn [tx-cfg] + (jobs/submit tx-cfg + {::jobs/name :echo + ::jobs/params {}}) + (throw (ex-info "rollback" {})))))) + (t/is (= 0.0 (counter-value metrics :jobs-submitted ["echo" "default"]))))) + +(t/deftest submit-and-terminal-writers-record-production-events + (let [metrics (make-metrics) + defs {:echo {::jobs/name :echo + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics metrics} + job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params {}})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + (jobs/complete cfg :job-id job-id :result {:ok true}) + (t/is (= 1.0 (counter-value metrics :jobs-submitted ["echo" "default"]))) + (t/is (= 1.0 (counter-value metrics :jobs-completed ["echo" "default" "completed"]))))) + +(t/deftest lifecycle-helpers-use-labels-as-is + (let [metrics (make-metrics) + cfg (metrics-cfg metrics)] + (jobs-metrics/record-submitted cfg "delete-object" "default") + (jobs-metrics/record-dispatched cfg "delete-object" "default" 2) + (jobs-metrics/record-outcome cfg "delete-object" "default" :completed) + (jobs-metrics/record-retry cfg "delete-object" "default" :backoff) + (jobs-metrics/record-orphan cfg "webhooks") + (jobs-metrics/record-rescheduled cfg "custom") + (jobs-metrics/record-gc-rows cfg :expired :deleted 3) + (jobs-metrics/record-cron cfg :submitted :none) + (jobs-metrics/record-request cfg :replied 12) + (jobs-metrics/record-legacy-execution cfg "delete-object" 42) + + (t/is (= 1.0 (counter-value metrics :jobs-submitted ["delete-object" "default"]))) + (t/is (= 2.0 (counter-value metrics :jobs-dispatched ["delete-object" "default"]))) + (t/is (= 1.0 (counter-value metrics :jobs-completed ["delete-object" "default" "completed"]))) + (t/is (= 1.0 (counter-value metrics :jobs-retries ["delete-object" "default" "backoff"]))) + (t/is (= 1.0 (counter-value metrics :jobs-orphaned ["webhooks"]))) + (t/is (= 1.0 (counter-value metrics :jobs-rescheduled ["custom"]))) + (t/is (= 3.0 (counter-value metrics :jobs-gc-rows ["expired" "deleted"]))) + (t/is (= 1.0 (counter-value metrics :jobs-cron-total ["submitted" "none"]))) + (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["replied"]))) + + (t/testing "the legacy histogram keeps the raw job name, with no queue" + (t/is (= 1.0 (histogram-count metrics :tasks-timing ["delete-object"])))) + (t/testing "like every other jobs metric now, it does not fold an unknown name into \"other\"" + (jobs-metrics/record-legacy-execution cfg :brand-new-job 7) + (t/is (= 1.0 (histogram-count metrics :tasks-timing ["brand-new-job"]))) + (t/is (= 0.0 (histogram-count metrics :tasks-timing ["other"])))))) + +(t/deftest labels-go-to-metrics-as-is + (t/testing "queues are lowercased, with other only when missing" + (t/is (= "unexpected" (jobs-metrics/queue-label "unexpected"))) + (t/is (= "default" (jobs-metrics/queue-label "default"))) + (t/is (= "default" (jobs-metrics/queue-label :default))) + (t/is (= "other" (jobs-metrics/queue-label nil)))) + (let [metrics (make-metrics) + cfg (metrics-cfg metrics)] + (jobs-metrics/record-outcome cfg "echo" "unexpected" :unexpected) + (t/is (= 1.0 (counter-value metrics :jobs-completed ["echo" "unexpected" "unexpected"]))) + (t/testing "a new value is recorded, never folded or dropped" + (jobs-metrics/record-gc-rows cfg :unknown :deleted 1) + (jobs-metrics/record-cron cfg :brand-new :whatever) + (jobs-metrics/record-request cfg :brand-new 5) + (t/is (= 1.0 (counter-value metrics :jobs-gc-rows ["unknown" "deleted"]))) + (t/is (= 1.0 (counter-value metrics :jobs-cron-total ["brand-new" "whatever"]))) + (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["brand-new"])))))) + +(t/deftest sampler-is-wired-and-does-not-start-on-read-only + (t/is (contains? main/worker-config :app.jobs.metrics/sampler)) + (let [metrics (make-metrics) + cfg {::db/pool th/*pool* + ::mtx/metrics metrics}] + (with-redefs [db/read-only? (constantly true)] + (t/is (nil? (ig/init-key :app.jobs.metrics/sampler cfg)))) + (let [sampler (ig/init-key :app.jobs.metrics/sampler cfg)] + (try + (t/is (some? sampler)) + (finally + (ig/halt-key! :app.jobs.metrics/sampler sampler)))))) + +(t/deftest unknown-names-go-to-metrics-as-is + (let [metrics (make-metrics) + cfg (metrics-cfg metrics)] + (jobs-metrics/record-submitted cfg "unknown-job" "default") + (t/is (= 1.0 (counter-value metrics :jobs-submitted ["unknown-job" "default"]))))) + +(t/deftest backlog-sampler-updates-status-and-age-gauges + (let [metrics (make-metrics) + cfg {::db/pool th/*pool* + ::mtx/metrics metrics} + now (ct/now)] + (th/db-insert! :job {:id (uuid/next) + :name "echo" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "new" + :scheduled-at now + :created-at (ct/in-past {:minutes 2}) + :modified-at now}) + (jobs-metrics/sample-backlog cfg) + (t/is (= 1.0 (gauge-value metrics :jobs-backlog ["new"]))) + (t/is (= 0.0 (gauge-value metrics :jobs-backlog ["completed"]))) + (t/is (= 0.0 (gauge-value metrics :jobs-backlog ["aborted"]))) + (t/is (>= (gauge-value metrics :jobs-oldest-pending-age []) 120.0)))) + +(t/deftest event-counter-has-no-labels-and-follows-the-events + (let [metrics (make-metrics) + cfg {::db/pool th/*pool* + ::mtx/metrics metrics} + defs {:echo {::jobs/name :echo + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg2 (assoc cfg ::jobs/defs defs) + job-id (jobs/submit cfg2 {::jobs/name :echo ::jobs/params {}})] + (jobs/claim cfg2 job-id (:scheduled-at (jobs/get-job cfg2 job-id))) + (jobs/complete cfg2 :job-id job-id :result {:ok true}) + (t/testing "one start and one end, counted together with no labels" + (t/is (= 2.0 (counter-value metrics :jobs-events-total [])))) + + (t/testing "a transition that affects no row adds no event and no count" + (t/is (= 0 (jobs/complete cfg2 :job-id job-id :result {:ok true}))) + (t/is (= 2.0 (counter-value metrics :jobs-events-total [])))))) + +(t/deftest event-counter-follows-a-lifecycle-write-that-outlives-the-caller + (let [metrics (make-metrics) + defs {:echo {::jobs/name :echo + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics metrics} + job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params {}})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + (t/is (= 1.0 (counter-value metrics :jobs-events-total []))) + + ;; the completion runs inside a transaction that rolls back. It owns + ;; its own, so the event and both counters survive the caller. + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [{:keys [::db/conn]}] + (jobs/complete (assoc cfg ::db/conn conn) + :job-id job-id :result {:ok true}))) + + (t/testing "the end event is stored and counted" + (t/is (= 2.0 (counter-value metrics :jobs-events-total []))) + (t/is (= 2 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event"]))))) + + (t/testing "the terminal counter followed its own commit, not the caller one" + (t/is (= 1.0 (counter-value metrics :jobs-completed ["echo" "default" "completed"])))))) + +(t/deftest progress-counter-follows-the-report-that-outlives-the-caller + (let [metrics (make-metrics) + defs {:echo {::jobs/name :echo + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics metrics} + job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params {}})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + (let [before (counter-value metrics :jobs-events-total [])] + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [{:keys [::db/conn]}] + (jobs/heartbeat (assoc cfg ::db/conn conn) + :job-id job-id + :progress {:current 1} + ::jobs/force? true))) + (t/testing "the report is stored and counted, the caller rollback drops neither" + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event + WHERE job_id = ? AND kind = 'progress'" + job-id])))) + (t/is (= (inc before) (counter-value metrics :jobs-events-total []))))))) diff --git a/backend/test/backend_tests/jobs_request_test.clj b/backend/test/backend_tests/jobs_request_test.clj new file mode 100644 index 0000000000..bb45c500b3 --- /dev/null +++ b/backend/test/backend_tests/jobs_request_test.clj @@ -0,0 +1,308 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.jobs-request-test + (:require + [app.common.generic-pool :as gpool] + [app.common.json :as json] + [app.common.time :as ct] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] + [app.redis :as rds] + [app.worker :as wrk] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [integrant.core :as ig]) + (:import + io.lettuce.core.api.sync.RedisCommands + io.prometheus.client.Counter + io.prometheus.client.Counter$Child + java.lang.AutoCloseable)) + +(t/use-fixtures :once th/state-init) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-metrics [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:jobs-requests-total + :jobs-request-timing])})) + +(defn- make-pool + ([] (make-pool (get th/*system* :app.metrics/metrics))) + ([metrics] + (-> {::jobs/request-pool {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics metrics}} + (ig/expand) + (ig/init) + (get ::jobs/request-pool)))) + +(defn- queue-key + [queue] + (wrk/queue-key (cf/get :tenant) queue)) + +(defn- make-cfg + [pool] + {::jobs/request-pool pool}) + +(defn- clear-queues + [] + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (let [cmd (.-cmd conn) + keys (vec (.keys ^RedisCommands cmd + (str jobs/reply-key-prefix ":" (cf/get :tenant) ":*")))] + (when (seq keys) + (rds/del conn keys)) + (rds/del conn (queue-key :media))) + (finally + (rds/close conn))))) + +(defn- test-fixture [next] + (th/database-reset + (fn [] + (clear-queues) + (next)))) + +(t/use-fixtures :each test-fixture) + +(defn- with-responder + "Start a responder on :media that answers the next request with the + provided response map (usually {:ok ...} or {:error ...}). Returns a + future that resolves to the decoded request payload [request-id, + reply-key, cmd, params]." + [pool response-fn & {:keys [delay-ms timeout-ms]}] + (let [timeout-ms (or timeout-ms 10000) + out (promise)] + (let [fut (future + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (when delay-ms + (Thread/sleep (long delay-ms))) + (let [key (queue-key :media) + [_ payload] (rds/blpop conn [key] (ct/duration {:millis timeout-ms}))] + (if (nil? payload) + (deliver out ::no-request) + (let [[request-id reply-key cmd params :as decoded] (json/decode payload)] + (deliver out decoded) + (jobs/reply {::rds/pool (::jobs/pool pool) + ::mtx/metrics (::mtx/metrics pool)} + reply-key + (response-fn params))))) + (finally + (rds/close conn)))))] + {:future fut + :payload out}))) + +(defn- get-ttl + [key] + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (let [cmd (.-cmd conn)] + (.ttl ^RedisCommands cmd key)) + (finally + (rds/close conn))))) + +(defn- key-exists? + [key] + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (let [cmd (.-cmd conn)] + (pos? (.exists ^RedisCommands cmd (make-array String 1 key)))) + (finally + (rds/close conn))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- counter-value [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(t/deftest request-pool-keeps-its-own-metrics + (let [metrics-a (make-metrics) + metrics-b (make-metrics) + pool-a (make-pool metrics-a) + pool-b (make-pool metrics-b)] + (t/is (identical? metrics-a (::mtx/metrics pool-a))) + (t/is (identical? metrics-b (::mtx/metrics pool-b))))) + +(t/deftest request-metrics-use-the-final-outcome + (let [metrics (make-metrics) + pool (make-pool metrics) + cfg (make-cfg pool)] + (with-responder pool (fn [_params] {:ok {:value 1}})) + (t/is (= {:value 1} + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {}}))) + (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["replied"]))) + (t/is (thrown-with-msg? Exception #"timeout waiting for the job reply" + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {} + ::jobs/timeout (ct/duration {:millis 100})}))) + (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["timeout"]))) + (t/is (= 0.0 (counter-value metrics :jobs-requests-total ["sent"]))))) + +(t/deftest request-returns-the-ok-reply + (let [pool (make-pool) + cfg (make-cfg pool) + payload (with-responder pool (fn [_params] {:ok {:value 42}}))] + + (let [result (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1}})] + (t/is (= {:value 42} result))) + + (t/testing "the worker receives the expected payload shape" + (let [[request-id reply-key cmd params :as decoded] (deref (:payload payload) 10000 ::timeout)] + (t/is (string? request-id)) + (t/is (string? reply-key)) + (t/is (str/starts-with? reply-key (str jobs/reply-key-prefix ":" (cf/get :tenant) ":"))) + (t/is (= "process" cmd)) + (t/is (= {"x" 1} params) + (str "params should be plain JSON: " (pr-str decoded))))))) + +(t/deftest request-propagates-the-error-reply + (let [pool (make-pool) + cfg (make-cfg pool) + _ (with-responder pool + (fn [_params] + {:error {:code :processing-failed + :hint "something went wrong"}}))] + (t/is (thrown-with-msg? Exception #"something went wrong" + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1}}))))) + +(t/deftest request-times-out-and-cleans-the-reply-key + (let [pool (make-pool) + cfg (make-cfg pool)] + (t/is (thrown-with-msg? Exception #"timeout waiting for the job reply" + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1} + ::jobs/timeout (ct/duration {:millis 500})}))) + + (t/testing "no orphan reply key is left behind" + ;; there is no way to know the exact reply key (random request-id); + ;; check no reply keys exist at all after the timeout + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (let [cmd (.-cmd conn) + keys (.keys ^RedisCommands cmd + (str jobs/reply-key-prefix ":" (cf/get :tenant) ":*"))] + (t/is (empty? (vec keys)))) + (finally + (rds/close conn))))))) + +(t/deftest request-pool-returns-connections-on-success-and-failure + (let [pool (make-pool) + cfg (make-cfg pool) + default-timeout (-> (cf/get-jobs-request-timeout) + (ct/plus (ct/duration {:seconds 30})))] + + (t/testing "successful request does not consume the connection" + (with-responder pool (fn [_params] {:ok {:a 1}})) + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1}}) + + (t/testing "and the next request works with a pooled connection" + (with-responder pool (fn [_params] {:ok {:b 2}})) + (t/is (= {:b 2} (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 2}}))) + + (t/testing "connection command timeout is restored after use" + (with-open [^AutoCloseable pooled (gpool/get (::jobs/pool pool))] + (let [conn @pooled] + (t/is (= (.toMillis ^java.time.Duration default-timeout) + (.toMillis ^java.time.Duration (rds/get-timeout conn))))))))))) + +(t/deftest request-supports-larger-per-call-timeout + (let [pool (make-pool) + cfg (make-cfg pool) + _ (with-responder pool (fn [_params] {:ok {:slow 1}}))] + + (t/testing "an override bigger than the default works (command timeout raised per call)" + (t/is (= {:slow 1} + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1} + ::jobs/timeout (ct/duration {:seconds 130})})))))) + +(t/deftest request-raises-connection-timeout-to-per-call-override + (let [pool (make-pool) + cfg (make-cfg pool) + seen (atom nil) + orig rds/set-timeout + override (ct/duration {:seconds 999}) + _ (with-responder pool (fn [_params] {:ok {:fast 1}}))] + ;; an override above the pool default is honored, not capped + (with-redefs [rds/set-timeout (fn [conn timeout] + (reset! seen timeout) + (orig conn timeout))] + (t/is (= {:fast 1} + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1} + ::jobs/timeout override})))) + (t/is (= (.toMillis ^java.time.Duration (ct/plus override (ct/duration {:seconds 30}))) + (.toMillis ^java.time.Duration @seen))))) + +(t/deftest late-reply-is-cleaned-by-the-worker-expire + (let [pool (make-pool) + cfg (make-cfg pool) + ;; the responder replies 2s after the caller timeout (500ms) + responder (with-responder pool + (fn [_params] {:ok {:late 1}}) + :delay-ms 2000)] + + (t/is (thrown-with-msg? Exception #"timeout waiting for the job reply" + (jobs/request cfg {::jobs/queue :media + ::jobs/cmd :process + ::jobs/params {:x 1} + ::jobs/timeout (ct/duration {:millis 500})}))) + + (t/testing "the late reply key exists (recreated by the worker) with a short TTL" + (deref (:future responder) 10000 ::timeout) + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (let [cmd (.-cmd conn) + keys (vec (.keys ^RedisCommands cmd + (str jobs/reply-key-prefix ":" (cf/get :tenant) ":*")))] + (t/is (= 1 (count keys))) + (let [ttl (.ttl ^RedisCommands cmd (first keys))] + (t/is (pos? ttl)) + (t/is (<= ttl 60)))) + (finally + (rds/close conn))))))) + +(t/deftest request-creates-no-job-row + (let [pool (make-pool) + cfg (make-cfg pool) + _ (with-responder pool (fn [_params] {:ok {}}))] + (jobs/request cfg {::jobs/queue :media ::jobs/cmd :process ::jobs/params {}}) + (t/is (zero? (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job"])))))) diff --git a/backend/test/backend_tests/jobs_test.clj b/backend/test/backend_tests/jobs_test.clj new file mode 100644 index 0000000000..2aba405738 --- /dev/null +++ b/backend/test/backend_tests/jobs_test.clj @@ -0,0 +1,1434 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.jobs-test + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.metrics :as-alias mtx] + [app.msgbus :as mbus] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [integrant.core :as ig])) + +(t/use-fixtures :once th/state-init) + +(defn- test-fixture [next] + (th/database-reset next)) + +(t/use-fixtures :each test-fixture) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JOB-DEF (plain handler + precompiled init-key) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn echo-handler + "Plain handler function, importable and testable without integrant. + Dual behavior: when invoked in-process (no job-id in cfg), heartbeat/progress + are no-ops. When invoked via the runner (with job-id in cfg), they write to + the job row. This allows the same handler to be tested both ways." + [cfg params] + (when (::jobs/job-id cfg) + (jobs/heartbeat cfg) + (jobs/heartbeat cfg :progress {:current 1 :stage "half"})) + params) + +(def schema:echo-params + [:map + [:text ::sm/text] + [:object :keyword] + [:deleted-at ::ct/inst] + [:id ::sm/uuid] + [:file-id {:optional true} ::sm/uuid]]) + +(defmethod ig/init-key ::echo-job + [_ cfg] + {::jobs/name :echo + ::jobs/schema schema:echo-params + ::jobs/handler (fn [_context params] + (echo-handler cfg params)) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params)}) + +(defn- get-job-defs + [] + (-> {::jobs/defs {:echo (ig/ref ::echo-job)} + ::echo-job {::db/pool th/*pool*}} + (ig/expand) + (ig/init) + (get ::jobs/defs))) + +(defn- make-cfg + [defs] + {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics (get th/*system* :app.metrics/metrics)}) + +(defn- make-params + [] + {:text "hello" + :object :snapshot + :deleted-at (ct/now) + :id (uuid/next) + :file-id (uuid/next)}) + +(defn- insert-job-statement? + "True when a `db/exec-one!` call is the job INSERT, whatever the + argument shape (a `[sql & params]` vector or a bare SQL string)." + [args] + (let [stmt (second args) + sql (if (string? stmt) stmt (first stmt))] + (and (string? sql) (str/starts-with? sql "insert into job")))) + +(def ^:private test-error + {:type :internal + :code :boom + :hint "boom"}) + +(defn- get-progresss + "Progress events of a job, oldest first, with decoded payloads." + [job-id] + (->> (th/db-exec! ["SELECT payload FROM job_event + WHERE job_id = ? AND kind = 'progress' + ORDER BY created_at ASC, id ASC" + job-id]) + (mapv #(db/decode-json-pgobject (:payload %))))) + +(defn- fake-msgbus + "A minimal msgbus that records every publication on the atom." + [messages] + (reify mbus/IMsgBus + (-sub [_ _ _] nil) + (-pub [_ topic message] + (swap! messages conj {:topic topic :message message}) + nil) + (-purge [_ _] nil))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest submit-persists-optional-profile-and-resource-references + (let [cfg (make-cfg (get-job-defs)) + profile-id (:id (th/create-profile* 1)) + resource-id (uuid/next)] + ;; the resource reference points to a real storage object: the column + ;; is a foreign key kept for garbage collection + (th/db-insert! :storage-object {:id resource-id :backend "test"}) + (let [internal-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + owned-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/profile-id profile-id + ::jobs/resource-id resource-id}) + internal-row (jobs/get-job cfg internal-id) + owned-row (jobs/get-job cfg owned-id)] + + (t/testing "profile_id stays nullable for internal jobs" + (t/is (nil? (:profile-id internal-row))) + (t/is (nil? (:resource-id internal-row)))) + + (t/testing "an explicit profile is persisted as the owner of the job" + (t/is (= profile-id (:profile-id owned-row)))) + + (t/testing "the resource reference is a column, not part of params" + (t/is (= resource-id (:resource-id owned-row))) + (t/is (not (contains? (:params owned-row) :resource-id))))))) + +(t/deftest submit-rejects-invalid-profile-and-resource-references + (let [cfg (make-cfg (get-job-defs))] + (t/testing "a non uuid profile-id is rejected before the insert" + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/profile-id "not-a-uuid"})))) + (t/testing "a non uuid resource-id is rejected before the insert" + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/resource-id "not-a-uuid"})))) + (t/testing "no job row was created by the rejected submits" + (t/is (= 0 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job"]))))))) + +(t/deftest progress-report-validates-its-payload + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + + (t/testing "current is mandatory" + (t/is (thrown? Exception + (jobs/heartbeat cfg :job-id job-id :progress {:total 10})))) + + (t/testing "unknown keys are rejected" + (t/is (thrown? Exception + (jobs/heartbeat cfg :job-id job-id + :progress {:current 1 :step 1})))) + + (t/testing "current must not be negative" + (t/is (thrown-with-msg? Exception #"negative" + (jobs/heartbeat cfg :job-id job-id + :progress {:current -1})))) + + (t/testing "total must be positive" + (t/is (thrown-with-msg? Exception #"positive" + (jobs/heartbeat cfg :job-id job-id + :progress {:current 0 :total 0})))) + + (t/testing "current must not be greater than total" + (t/is (thrown-with-msg? Exception #"not lower than current" + (jobs/heartbeat cfg :job-id job-id + :progress {:current 5 :total 4})))) + + (t/testing "stage is limited to 250 characters" + (t/is (thrown-with-msg? Exception #"250" + (jobs/heartbeat cfg :job-id job-id + :progress {:current 1 + :stage (apply str (repeat 251 "x"))}))) + (t/is (= 2 (jobs/heartbeat cfg :job-id job-id + :progress {:current 1 + :stage (apply str (repeat 250 "x"))}))) + (t/is (= [{:current 1 :stage (apply str (repeat 250 "x"))}] + (get-progresss job-id)))) + + (t/testing "a rejected report never reaches the durable log" + (t/is (= 1 (count (get-progresss job-id))))))) + +(t/deftest progress-event-publishes-msgbus-for-profile-jobs + (let [cfg (make-cfg (get-job-defs)) + profile-id (:id (th/create-profile* 1)) + messages (atom []) + job-cfg (assoc cfg ::mbus/msgbus (fake-msgbus messages)) + job-id (jobs/submit job-cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/profile-id profile-id})] + (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id + :progress {:current 3 :total 7}))) + (t/testing "the event is published on the topic of the job profile" + (t/is (= 1 (count @messages))) + (let [{:keys [topic message]} (first @messages)] + (t/is (= profile-id topic)) + (t/is (= :job-event (:type message))) + (t/is (= job-id (:job-id message))) + (t/is (= profile-id (:profile-id message))) + (t/is (= "progress" (:kind message))) + (t/is (= {:current 3 :total 7} (:payload message))) + (t/is (some? (:event-id message))) + (t/is (some? (:created-at message))))))) + +(t/deftest progress-event-of-internal-job-is-not-published + (let [cfg (make-cfg (get-job-defs)) + messages (atom []) + job-cfg (assoc cfg ::mbus/msgbus (fake-msgbus messages)) + job-id (jobs/submit job-cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id :progress {:current 1}))) + (t/testing "the event is stored but nothing is published" + (t/is (= 1 (count (get-progresss job-id)))) + (t/is (= [] @messages))))) + +(t/deftest progress-event-of-profile-job-requires-msgbus + (let [cfg (make-cfg (get-job-defs)) + profile-id (:id (th/create-profile* 1)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/profile-id profile-id})] + (t/testing "the forced route propagates the missing msgbus" + (t/is (thrown-with-msg? Exception #"require ::mbus/msgbus" + (jobs/heartbeat cfg + :job-id job-id + :progress {:current 1} + ::jobs/force? true))) + (t/is (= [] (get-progresss job-id)) + "the event must not be stored without its notification")) + + (t/testing "the handler route logs the failure and keeps the job alive" + (th/db-update! :job {:modified-at (ct/in-past {:minutes 5})} {:id job-id}) + (swap! jobs/heartbeats dissoc job-id) + (t/is (= 1 (jobs/heartbeat cfg :job-id job-id + :progress {:current 1}))) + (t/is (= [] (get-progresss job-id)))))) + +(t/deftest submit-validates-params-with-job-schema + (let [cfg (make-cfg (get-job-defs))] + (t/is (thrown-with-msg? Exception #"check error" + (jobs/submit cfg {::jobs/name :echo + ::jobs/params {:text "hello" + :object :snapshot + :deleted-at (ct/now) + :id (uuid/next) + :file-id "not-an-uuid"}})))) + + (t/testing "missing job definition raises a clear error" + (let [cfg (make-cfg (get-job-defs))] + (t/is (thrown-with-msg? Exception #"no job definition" + (jobs/submit cfg {::jobs/name :unknown + ::jobs/params {}}))))) + + (t/testing "the job name is a keyword, and only a keyword" + (let [cfg (make-cfg (get-job-defs))] + (t/is (thrown? clojure.lang.ExceptionInfo + (jobs/submit cfg {::jobs/name "echo" + ::jobs/params {}}))) + (t/is (thrown? clojure.lang.ExceptionInfo + (jobs/invoke (assoc cfg + ::jobs/name "echo" + ::jobs/params {}))))))) + +(t/deftest submit-persists-row-with-json-params + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/queue :test + ::jobs/delay 1000 + ::jobs/priority 200 + ::jobs/label "test-label" + ::jobs/max-retries 5}) + row (jobs/get-job cfg job-id)] + + (t/is (uuid? job-id)) + (t/is (= "echo" (:name row))) + (t/is (= (cf/get :tenant) (:tenant row))) + (t/is (= "test" (:queue row))) + (t/is (= "test-label" (:label row))) + (t/is (= 200 (:priority row))) + (t/is (= 5 (:max-retries row))) + (t/is (= "new" (:status row))) + + (t/testing "scheduled_at respects the submitted delay" + (let [delay (ct/duration {:seconds 1})] + (t/is (pos? (- (inst-ms (:scheduled-at row)) (inst-ms (ct/now)))) + (str "scheduled-at should be in the future: " + (pr-str (:scheduled-at row)))))) + + (t/testing "the row exposes params and no legacy columns" + (t/is (some? (:params row))) + (t/is (not (contains? row :props))) + (t/is (not (contains? row :progress))) + (t/is (not (contains? row :target)))) + + (t/testing "params are stored as plain JSON (no transit tags) and decoded" + (let [params (:params row)] + (t/is (map? params)) + (t/is (nil? (some-> (th/db-exec-one! ["SELECT params::text FROM job WHERE id = ?" + job-id]) + :params + (str/index-of "~#")))) + + (t/testing "round-trip: decoded values have proper clojure types" + (let [decoded (jobs/decode-params (jobs/get-job-def (get cfg ::jobs/defs) :echo) + (:params row))] + (t/is (keyword? (:object decoded))) + (t/is (= :snapshot (:object decoded))) + (t/is (uuid? (:id decoded))) + (t/is (uuid? (:file-id decoded))) + (t/is (ct/inst? (:deleted-at decoded))))))))) + +(t/deftest submit-dedupe-replaces-not-due-new-rows + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label "dedupe-label" + id1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/dedupe true + ::jobs/delay 10000}) + id2 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/dedupe true + ::jobs/delay 10000})] + + (t/is (uuid? id1)) + (t/is (uuid? id2)) + (t/is (not= id1 id2)) + + (t/testing "the first new row (not due yet) is removed" + (t/is (nil? (jobs/get-job cfg id1))) + + (t/testing "and only the new row remains" + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ?" label])))))))) + +(t/deftest submit-dedupe-keeps-due-and-running-rows + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label "dedupe-active-label" + id1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label}) + _ (th/db-update! :job {:status "running"} {:id id1}) + id2 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/dedupe true})] + + (t/is (some? (jobs/get-job cfg id1))) + (t/is (= 2 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ?" label]))))) + + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label "dedupe-due-label" + id1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label}) + _ (th/db-update! :job {:scheduled-at (ct/minus (ct/now) + (ct/duration {:seconds 5}))} + {:id id1}) + id2 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/dedupe true})] + + (t/is (some? (jobs/get-job cfg id1))) + (t/is (= 2 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ?" label])))))) + +(t/deftest submit-dedupe-leaves-another-tenants-rows-alone + ;; The DELETE matches name, queue and label. Without the tenant in the + ;; match it would reach into another instance's not-started jobs. + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label "dedupe-shared-label" + foreign-id (uuid/next)] + (th/db-insert! :job {:id foreign-id + :name "echo" + :tenant "other-tenant" + :queue "default" + :label label + :params (db/json params) + :status "new" + :priority 100 + :max-retries 3 + :retry-num 0 + :scheduled-at (ct/plus (ct/now) (ct/duration {:seconds 10})) + :created-at (ct/now) + :modified-at (ct/now)}) + (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/queue :default + ::jobs/label label + ::jobs/dedupe true + ::jobs/delay 10000}) + (t/is (some? (jobs/get-job cfg foreign-id)) + "another tenant's matching job must survive our dedupe") + (t/testing "and only our own row was written" + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ? + AND tenant = ?" + label (cf/get :tenant)]))))))) + +(t/deftest submit-dedupe-rolls-back-on-insert-failure + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + opts {::jobs/name :echo + ::jobs/params params + ::jobs/dedupe true + ::jobs/label "atomic-label"} + kept (jobs/submit cfg opts) + orig @#'db/exec-one!] + ;; fault the INSERT: the DELETE before it must roll back too + (alter-var-root #'db/exec-one! + (constantly (fn [& args] + (when (insert-job-statement? args) + (throw (ex-info "boom" {}))) + (apply orig args)))) + (try + (t/is (thrown? Exception (jobs/submit cfg opts))) + (t/testing "the original row survives, no duplicate left behind" + (t/is (some? (jobs/get-job cfg kept))) + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job WHERE label = ?" "atomic-label"]))))) + (finally + (alter-var-root #'db/exec-one! (constantly orig)))))) + +(t/deftest submit-dedupe-atomic-on-autocommit-conn + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + opts {::jobs/name :echo + ::jobs/params params + ::jobs/dedupe true + ::jobs/label "atomic-autocommit-label"} + kept (jobs/submit cfg opts) + orig @#'db/exec-one!] + ;; a raw connection outside any transaction: DELETE+INSERT must + ;; still share one transaction opened on it + (with-open [conn (db/open th/*pool*)] + (alter-var-root #'db/exec-one! + (constantly (fn [& args] + (when (insert-job-statement? args) + (throw (ex-info "boom" {}))) + (apply orig args)))) + (try + (t/is (thrown? Exception + (jobs/submit (assoc cfg ::db/conn conn) opts))) + (t/testing "the original row survives, no duplicate left behind" + (t/is (some? (jobs/get-job cfg kept))) + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job WHERE label = ?" "atomic-autocommit-label"]))))) + (finally + (alter-var-root #'db/exec-one! (constantly orig))))))) + +(t/deftest submit-requires-metrics-on-bare-connectable + (let [defs (get-job-defs) + prev @@#'jobs/defs-registry] + (try + (reset! @#'jobs/defs-registry defs) + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/submit th/*pool* {::jobs/name :echo + ::jobs/params (make-params)}))) + (finally + (reset! @#'jobs/defs-registry prev))))) + +(t/deftest plain-handler-is-testable-without-integrant + (let [params (make-params)] + (t/is (= params (echo-handler {} params)))) + + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params params})] + (t/testing "handler receives cfg with job-id context for heartbeats" + (t/is (= params (echo-handler (assoc cfg ::jobs/job-id job-id) + params)))))) + +(t/deftest submit-strips-rollback-testing-flag-from-params + (let [cfg (make-cfg (get-job-defs)) + params (assoc (make-params) :rollback? true) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params params})] + (t/testing "durable rows never carry the in-process escape hatch" + (t/is (nil? (:rollback? (:params (jobs/get-job cfg job-id)))))))) + +(t/deftest heartbeat-respects-throttle + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + + (t/testing "first heartbeat writes" + ;; Backdate modified-at so the beat is unambiguous: the assertion + ;; compares against the value we set, not against created-at, which + ;; can land on the same millisecond. + (let [backdated (ct/in-past {:seconds 5})] + (th/db-update! :job {:modified-at backdated} {:id job-id}) + (t/is (= 1 (jobs/heartbeat cfg :job-id job-id))) + (let [row (jobs/get-job cfg job-id)] + (t/is (> (inst-ms (:modified-at row)) (inst-ms backdated)))))) + + (t/testing "immediate second heartbeat does not write (throttled)" + (let [row1 (jobs/get-job cfg job-id) + _ (jobs/heartbeat cfg :job-id job-id) + row2 (jobs/get-job cfg job-id)] + (t/is (= (inst-ms (:modified-at row1)) + (inst-ms (:modified-at row2)))))))) + +(t/deftest heartbeat-skips-terminal-states + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/testing "terminal states are never touched by heartbeat" + (th/db-update! :job {:status "completed" + :modified-at (ct/in-past {:days 10})} + {:id job-id}) + (swap! @#'jobs/heartbeats dissoc job-id) + (let [before (jobs/get-job cfg job-id)] + (jobs/heartbeat cfg :job-id job-id) + (t/is (= (inst-ms (:modified-at before)) + (inst-ms (:modified-at (jobs/get-job cfg job-id))))))))) + +(t/deftest progress-events-respect-throttle-and-skip-terminal-states + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + + (t/testing "first progress report appends a progress event" + (t/is (= 2 (jobs/heartbeat cfg :job-id job-id + :progress {:current 1 :total 10}))) + (t/is (= [{:current 1 :total 10}] (get-progresss job-id)))) + + (t/testing "immediate second progress report is throttled" + (t/is (= 0 (jobs/heartbeat cfg :job-id job-id + :progress {:current 2 :total 10}))) + (t/is (= [{:current 1 :total 10}] (get-progresss job-id))) + + (t/testing "after the throttle window elapses it appends again" + (swap! @#'jobs/progresses + (fn [m] + (update-in m [job-id] + #(ct/minus % + (ct/duration {:seconds 2}))))) + (jobs/heartbeat cfg :job-id job-id :progress {:current 3 :total 10}) + (t/is (= [{:current 1 :total 10} + {:current 3 :total 10}] + (get-progresss job-id)))))) + + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/testing "terminal states never get a progress event" + (th/db-update! :job {:status "completed"} {:id job-id}) + (swap! @#'jobs/progresses dissoc job-id) + (t/is (= 0 (jobs/heartbeat cfg :job-id job-id + :progress {:current 9}))) + (t/is (= [] (get-progresss job-id)))))) + +(t/deftest throttle-prune-removes-stale-entries-keeps-fresh + "When the throttle map exceeds prune-threshold, stale entries (older than + prune-window) are removed and fresh entries are kept." + (let [cfg (make-cfg (get-job-defs)) + job-id-1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + job-id-2 (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + now (ct/now) + stale-time (ct/minus now (ct/duration {:hours 2})) ;; older than 1h window + fresh-time (ct/minus now (ct/duration {:minutes 30}))] ;; within 1h window + + ;; Fill the heartbeats map past the threshold with one stale and one fresh entry + (with-redefs [jobs/prune-threshold 0] ;; force prune on next call + (reset! @#'jobs/heartbeats + {job-id-1 stale-time + job-id-2 fresh-time}) + + ;; Trigger a heartbeat for a new job (should trigger prune) + (let [job-id-3 (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (jobs/heartbeat cfg :job-id job-id-3) + + ;; After prune: stale entry (job-id-1) should be gone, fresh (job-id-2) should remain + (let [state @jobs/heartbeats] + (t/is (not (contains? state job-id-1)) "stale entry removed") + (t/is (contains? state job-id-2) "fresh entry kept") + (t/is (contains? state job-id-3) "new entry added")))))) + +(t/deftest cancel-skips-running-and-terminal-jobs + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + + (t/testing "pending job can be cancelled" + (t/is (= 1 (jobs/cancel cfg job-id))) + (t/is (= "cancelled" (:status (jobs/get-job cfg job-id)))) + + (t/testing "already cancelled job is not affected again" + (t/is (zero? (jobs/cancel cfg job-id)))))) + + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/testing "running job cannot be cancelled" + (th/db-update! :job {:status "running"} {:id job-id}) + (t/is (zero? (jobs/cancel cfg job-id))) + (t/is (= "running" (:status (jobs/get-job cfg job-id))))))) + +(t/deftest cancel-affects-scheduled-and-retry-jobs + (let [cfg (make-cfg (get-job-defs))] + (doseq [status ["scheduled" "retry"]] + (t/testing (str status " job can be cancelled") + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (th/db-update! :job {:status status} {:id job-id}) + (t/is (= 1 (jobs/cancel cfg job-id))) + (t/is (= "cancelled" (:status (jobs/get-job cfg job-id))))))))) + +(t/deftest get-user-status-maps-internal-statuses + (t/are [status expected] (= expected (jobs/get-user-status status)) + "new" "pending" + "scheduled" "pending" + "retry" "pending" + :new "pending" + "running" "running" + "completed" "completed" + "failed" "failed" + "cancelled" "failed" + "aborted" "failed")) + +(t/deftest job-defs-registry-validates-definitions + (t/testing "valid job-def map passes the assert" + (t/is (some? (get-job-defs)))) + + (t/testing "registry name mismatch is detected" + (t/is (thrown? Exception + (ig/init {::jobs/defs {::echo-job + {::jobs/name :other + ::jobs/schema schema:echo-params + ::jobs/handler (fn [_context params] + (echo-handler nil params)) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params)}}})))) + + (t/testing "missing decoder/validator is detected" + (t/is (thrown? Exception + (ig/init {::jobs/defs {::echo-job + {::jobs/name :echo + ::jobs/schema schema:echo-params + ::jobs/handler (fn [_context params] + params)}}}))))) + +(t/deftest generic-schema-round-trip-preserves-type-sensitive-fields + "For each registered job-def, verify that type-sensitive fields (uuids, insts) + survive the JSON round-trip (db/json → decode). This catches the F2 class of + bug where uuid types are lost during the transit→JSON switch." + (let [defs (get-job-defs)] + (t/is (pos? (count defs)) "should have at least one job-def") + + ;; Test the echo job-def which we know exists in the test registry + (t/testing "echo job-def preserves types through JSON round-trip" + (let [echo-def (get defs :echo) + _ (t/is (some? echo-def) "echo job-def should exist") + ;; Create a representative params map with type-sensitive fields + sample-params {:text "test" + :object :snapshot + :deleted-at (ct/now) + :id (uuid/random) + :file-id (uuid/random)} + + ;; Round-trip through JSON (simulates the job table storage) + ;; db/json returns a PGobject, which is what decode-params expects + pg-obj (db/json sample-params) + decoded (jobs/decode-params echo-def pg-obj)] + + ;; Verify type-sensitive fields survived + (t/is (uuid? (:id decoded)) "id should be uuid after decode") + (t/is (uuid? (:file-id decoded)) "file-id should be uuid after decode") + (t/is (inst? (:deleted-at decoded)) "deleted-at should be inst after decode"))))) + +(t/deftest invoke-falls-back-to-global-registry + "Verify that invoke! uses the global registry fallback when ::defs is not + on the cfg, consistent with submit!." + ;; Rebind the private registry atom instead of overwriting global state: + ;; it auto-restores on exit, so no later test can observe it. Note + ;; get-job-defs itself populates the global via ig/init (established + ;; pattern, also used by the sibling tests); the snapshot below is + ;; taken after that, so it pins exactly this test's rebinding. + (let [defs (get-job-defs) + before @@#'jobs/defs-registry] + (with-redefs [jobs/defs-registry (atom defs)] + ;; cfg WITHOUT ::jobs/defs to exercise the fallback; the echo + ;; job-def is known to exist in the rebound registry + (let [cfg {::db/pool th/*pool*} + result (jobs/invoke (assoc cfg ::jobs/name :echo + ::jobs/params (make-params)))] + ;; Should not throw; should find the job-def via the fallback. + ;; The result is the params map (echo-handler returns params). + (t/is (some? result)))) + (t/testing "global registry untouched" + (t/is (= before @@#'jobs/defs-registry))))) + +(t/deftest heartbeat-and-progress-noop-when-job-id-nil + "When *job-id* is nil (in-process invoke! without a job row), + heartbeat! and progress! are no-ops — they do not write to the + database or update the throttle atoms." + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + + (t/testing "heartbeat! is a no-op when *job-id* is nil" + (let [row-before (jobs/get-job cfg job-id)] + (binding [jobs/*job-id* nil] + (jobs/heartbeat cfg)) + (let [row-after (jobs/get-job cfg job-id)] + (t/is (= (inst-ms (:modified-at row-before)) + (inst-ms (:modified-at row-after))) + "modified-at should not change")))) + + (t/testing "progress reporting is a no-op when *job-id* is nil" + (binding [jobs/*job-id* nil] + (t/is (nil? (jobs/heartbeat cfg :progress {:current 1})))) + (t/is (= [] (get-progresss job-id)) + "no progress event should be stored")))) + +(t/deftest progress-noop-on-terminal-states + "A progress report never stores an event when the job is already in a + terminal state (completed, failed, cancelled, aborted): the row is locked and + checked before the insert, so a report can never race a terminal + transition." + (let [cfg (make-cfg (get-job-defs))] + (doseq [status ["completed" "failed" "cancelled" "aborted"]] + (t/testing (str "progress is a no-op on " status " status") + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (th/db-update! :job {:status status} {:id job-id}) + ;; reset the throttle so should-write? would allow the write + (swap! jobs/progresses dissoc job-id) + (binding [jobs/*job-id* job-id] + (t/is (= 0 (jobs/heartbeat cfg :progress {:current 1})))) + (t/is (= [] (get-progresss job-id)))))))) + +(t/deftest defs-halt-clears-module-registry + (let [prev @@#'jobs/defs-registry] + ;; init populates the module registry + (get-job-defs) + (t/is (contains? @@#'jobs/defs-registry :echo)) + (try + (ig/halt-key! ::jobs/defs nil) + (t/is (= {} @@#'jobs/defs-registry)) + (finally + (reset! @#'jobs/defs-registry prev))))) + +(t/deftest heartbeat-bypasses-caller-transaction + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + ;; backdate so the beat is strictly greater (no same-millis flake) + (th/db-update! :job {:modified-at (ct/in-past {:minutes 5})} {:id job-id}) + ;; beat inside a transaction that is rolled back: the beat must + ;; still be visible (it went through the pool, not the tx) + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [{:keys [::db/conn]}] + (jobs/heartbeat (assoc cfg ::db/conn conn) :job-id job-id))) + (let [row (jobs/get-job cfg job-id)] + (t/is (> (inst-ms (:modified-at row)) + (inst-ms (ct/in-past {:minutes 5}))))))) + +(t/deftest progress-bypasses-caller-transaction + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [{:keys [::db/conn]}] + (jobs/heartbeat (assoc cfg ::db/conn conn) + :job-id job-id + :progress {:current 1}))) + (t/is (= [{:current 1}] (get-progresss job-id))))) + +(t/deftest progress-notification-survives-a-rolled-back-caller + (let [cfg (make-cfg (get-job-defs)) + profile-id (:id (th/create-profile* 1)) + messages (atom []) + job-cfg (assoc cfg ::mbus/msgbus (fake-msgbus messages)) + job-id (jobs/submit job-cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/profile-id profile-id})] + ;; the report owns its transaction, so it commits and publishes even + ;; though the transaction that triggered it rolls back + (db/tx-run! (assoc job-cfg ::db/rollback true) + (fn [{:keys [::db/conn]}] + (jobs/heartbeat (assoc job-cfg ::db/conn conn) + :job-id job-id + :progress {:current 1}))) + (t/testing "the event is stored" + (t/is (= [{:current 1}] (get-progresss job-id)))) + (t/testing "and the notification went out with its own commit" + (t/is (= 1 (count @messages))) + (t/is (= "progress" (:kind (:message (first @messages)))))))) + +(t/deftest heartbeat-requires-a-cfg-map + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/testing "a bare pool is a caller error, not something to accommodate" + (t/is (thrown? Exception + (jobs/heartbeat th/*pool* + :job-id job-id + :progress {:current 1})))))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HANDLER CONTEXT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- mk-row + "A job row as the runner and make-context see it." + [overrides] + (merge {:id (uuid/next) + :name "echo" + :tenant (cf/get :tenant) + :queue "default" + :label nil + :resource-id nil + :retry-num 0 + :max-retries 3} + overrides)) + +(t/deftest make-context-selects-exactly-the-agreed-keys + (let [context (jobs/make-context (mk-row {}))] + (t/is (= #{:id :name :label :resource-id} (set (keys context)))) + (t/testing "the queue is a routing detail of the dispatcher, not context" + (t/is (not (contains? context :queue)))) + (t/testing "nor is the tenant that owns the queue" + (t/is (not (contains? context :tenant)))) + (t/testing "the retry keys belong to the runner, not to a handler" + (t/is (not (contains? context :retry-num))) + (t/is (not (contains? context :max-retries))) + (t/is (not (contains? context :attempt)))) + (t/testing "the context is a plain map, not the row" + (t/is (not= (set (keys (mk-row {}))) (set (keys context))))))) + +(t/deftest make-context-normalizes-an-empty-label + (t/testing "the empty label is the submit default for no label" + (t/is (nil? (:label (jobs/make-context (mk-row {:label ""}))))) + (t/is (nil? (:label (jobs/make-context (mk-row {:label nil})))))) + (t/testing "a real label is carried as is" + (t/is (= "my-label" (:label (jobs/make-context (mk-row {:label "my-label"}))))))) + +(t/deftest make-context-always-carries-the-resource-reference + (t/testing "the key exists even when there is no resource" + (let [context (jobs/make-context (mk-row {}))] + (t/is (contains? context :resource-id)) + (t/is (nil? (:resource-id context))))) + (t/testing "a resource reference is carried as is" + (let [resource-id (uuid/next)] + (t/is (= resource-id + (:resource-id (jobs/make-context (mk-row {:resource-id resource-id})))))))) + +(t/deftest make-context-rejects-invalid-rows + (t/testing "a missing id is rejected" + (t/is (thrown? Exception (jobs/make-context (mk-row {:id nil}))))) + (t/testing "a missing name is rejected" + (t/is (thrown? Exception (jobs/make-context (mk-row {:name nil}))))) + (t/testing "a non uuid resource reference is rejected" + (t/is (thrown? Exception (jobs/make-context (mk-row {:resource-id "nope"})))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IN-PROCESS INVOCATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def received-contexts (atom [])) + +(defn- capture-defs + "Job-defs whose handler records the context it receives." + [] + {:echo {::jobs/name :echo + ::jobs/schema schema:echo-params + ::jobs/handler (fn [context params] + (swap! received-contexts conj context) + params) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params)}}) + +(t/deftest invoke-delivers-a-nil-context-without-a-row + (reset! received-contexts []) + (let [cfg (make-cfg (capture-defs)) + params (make-params)] + (t/is (= params (jobs/invoke (assoc cfg ::jobs/name :echo + ::jobs/params params)))) + (t/is (= [nil] @received-contexts)))) + +(t/deftest invoke-delivers-an-explicit-context + (reset! received-contexts []) + (let [cfg (make-cfg (capture-defs)) + params (make-params) + context (jobs/make-context (mk-row {}))] + (jobs/invoke (assoc cfg ::jobs/name :echo + ::jobs/params params + ::jobs/context context)) + (t/is (= [context] @received-contexts)) + (t/testing "an explicit context does not enable heartbeat by itself" + (t/is (= [] (get-progresss (:id context))))))) + +(t/deftest invoke-validates-an-explicit-context + (let [cfg (make-cfg (capture-defs))] + (t/testing "a partial context is rejected" + (t/is (thrown? Exception + (jobs/invoke (assoc cfg + ::jobs/name :echo + ::jobs/params (make-params) + ::jobs/context {:id (uuid/next)}))))) + (t/testing "an extra key is rejected: the schema is closed" + (t/is (thrown? Exception + (jobs/invoke (assoc cfg + ::jobs/name :echo + ::jobs/params (make-params) + ::jobs/context + (assoc (jobs/make-context (mk-row {})) + :params {})))))))) + +(t/deftest invoke-job-id-is-independent-from-the-context + (reset! received-contexts []) + (let [cfg (make-cfg (capture-defs)) + params (make-params) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params params})] + (jobs/invoke (assoc cfg ::jobs/name :echo + ::jobs/params params + ::jobs/job-id job-id)) + (t/is (= [nil] @received-contexts) + "a job-id without a context still delivers a nil context") + (t/testing "the job-id is what makes the durable writes reach the row" + (t/is (pos? (jobs/heartbeat cfg :job-id job-id + :progress {:current 1}))) + (t/is (= [{:current 1}] (get-progresss job-id)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; LIFECYCLE EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- get-events + "Every event of a job, in insertion order." + [job-id] + (->> (th/db-exec! ["SELECT kind, payload FROM job_event + WHERE job_id = ? ORDER BY id ASC" job-id]) + (mapv (fn [{:keys [kind payload]}] + {:kind kind :payload (db/decode-json-pgobject payload)})))) + +(defn- get-kinds + [job-id] + (mapv :kind (get-events job-id))) + +(defn- get-outcomes + [job-id] + (mapv (comp :outcome :payload) (filter #(= "end" (:kind %)) (get-events job-id)))) + +(defn- mk-running + "A job already claimed by a worker, ready for a terminal transition." + [cfg] + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + job-id)) + +(t/deftest lifecycle-writers-require-metrics + ;; Every one of the six writers must check the metrics instance before + ;; it writes. Their metric lands inside a `db/after-commit` callback, + ;; and those swallow exceptions, so without the check a miswired cfg + ;; persists the row and loses the count with nothing but a log line. + (let [cfg (make-cfg (get-job-defs)) + bare (dissoc cfg ::mtx/metrics) + count-jobs (fn [] (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job"]))) + state (fn [id] (:status (jobs/get-job cfg id)))] + (t/testing "the guards exist at all" + ;; They are `clojure.core/assert` forms, and those are elided when + ;; *assert* is false at compile time. Without the flag the checks + ;; are not in the compiled code and every assertion below would + ;; fail for a reason that has nothing to do with the writers. + (t/is (true? *assert*) + "run the suite with PENPOT_FLAGS=\"enable-backend-asserts\"")) + + (t/testing "submit never reaches the insert, and writes no row" + (let [before (count-jobs)] + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/submit bare {::jobs/name :echo + ::jobs/params (make-params)}))) + (t/is (= before (count-jobs))))) + + (t/testing "claim leaves the pending job pending" + (let [id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/claim bare id (:scheduled-at (jobs/get-job cfg id))))) + (t/is (= "new" (state id))) + (t/is (= [] (get-kinds id))))) + + (t/testing "retry-job leaves the running job alone" + (let [id (mk-running cfg)] + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/retry-job bare id 1 (ct/in-past {:minutes 1}) + test-error :backoff))) + (t/is (= "running" (state id))) + (t/is (= ["start"] (get-kinds id))))) + + (t/testing "complete leaves the running job alone" + (let [id (mk-running cfg)] + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/complete bare :job-id id :result {:v 1}))) + (t/is (= "running" (state id))) + (t/is (= ["start"] (get-kinds id))))) + + (t/testing "fail leaves the running job alone" + (let [id (mk-running cfg)] + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/fail bare id test-error))) + (t/is (= "running" (state id))) + (t/is (= ["start"] (get-kinds id))))) + + (t/testing "cancel leaves the pending job pending" + (let [id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/is (thrown-with-msg? Exception #"missing ::mtx/metrics" + (jobs/cancel bare id))) + (t/is (= "new" (state id))) + (t/is (= [] (get-kinds id))))))) + +(t/deftest lifecycle-writes-own-their-transaction + (let [cfg (make-cfg (get-job-defs)) + ;; a lifecycle write reports on work that already happened, so it + ;; never joins the transaction of whoever called it. The row it + ;; needs is committed beforehand: an independent connection + ;; cannot see the uncommitted work of the transaction around it. + rolled-back + (fn [f] + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [{:keys [::db/conn]}] + (f (assoc cfg ::db/conn conn))))) + state (fn [job-id] + {:status (:status (jobs/get-job cfg job-id)) + :kinds (get-kinds job-id)})] + + (t/testing "claim starts the job and stores the start event" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + at (:scheduled-at (jobs/get-job cfg job-id))] + (t/is (= 1 (rolled-back #(jobs/claim % job-id at)))) + (t/is (= {:status "running" :kinds ["start"]} (state job-id))))) + + (t/testing "retry-job schedules the next attempt and stores the retry event" + (let [job-id (mk-running cfg)] + (t/is (= 1 (rolled-back #(jobs/retry-job % job-id 1 (ct/now) test-error :backoff)))) + (t/is (= {:status "retry" :kinds ["start" "retry"]} (state job-id))))) + + (t/testing "complete stores the end event with its result" + (let [job-id (mk-running cfg)] + (t/is (= 1 (rolled-back #(jobs/complete % :job-id job-id :result {:v 1})))) + (t/is (= {:status "completed" :kinds ["start" "end"]} (state job-id))))) + + (t/testing "fail stores the end event with its error" + (let [job-id (mk-running cfg)] + (t/is (= 1 (rolled-back #(jobs/fail % job-id test-error)))) + (t/is (= {:status "failed" :kinds ["start" "end"]} (state job-id))))) + + (t/testing "cancel stores the end event" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/is (= 1 (rolled-back #(jobs/cancel % job-id)))) + (t/is (= {:status "cancelled" :kinds ["end"]} (state job-id))))))) + +(t/deftest claim-records-a-start-event-with-the-attempt + (let [cfg (make-cfg (get-job-defs))] + (t/testing "the first attempt is 1" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + (t/is (= [{:kind "start" :payload {:attempt 1}}] (get-events job-id))))) + + (t/testing "a retried job claims attempt retry-num + 1" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (th/db-update! :job {:retry-num 2} {:id job-id}) + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + (t/is (= [{:kind "start" :payload {:attempt 3}}] (get-events job-id))))))) + +(t/deftest claim-that-affects-no-row-writes-no-event + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/is (= 0 (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))))) + (t/testing "only the first claim left a start event" + (t/is (= ["start"] (get-kinds job-id)))))) + +(t/deftest retry-job-records-a-retry-event-per-reason + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/testing "a backoff increments the counter and reports the new attempt" + (jobs/retry-job cfg job-id 1 (ct/now) test-error :backoff) + (t/is (= {:attempt 2 :reason "backoff"} + (:payload (last (get-events job-id)))))) + + (t/testing "a noop keeps the counter and re-reports the same attempt" + (jobs/retry-job cfg job-id 1 (ct/now) test-error :noop) + (t/is (= {:attempt 2 :reason "noop"} + (:payload (last (get-events job-id)))))) + + (t/testing "the counter on the row is the one we asked for" + (t/is (= 1 (:retry-num (jobs/get-job cfg job-id))))) + + (t/testing "a terminal job gets no retry and no event" + (th/db-update! :job {:status "failed"} {:id job-id}) + (t/is (= 0 (jobs/retry-job cfg job-id 2 (ct/now) test-error :backoff))) + (t/is (= ["start" "retry" "retry"] (get-kinds job-id)))))) + +(t/deftest complete-records-an-end-event + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/is (= 1 (jobs/complete cfg :job-id job-id :result {:value 1}))) + (t/is (= ["start" "end"] (get-kinds job-id))) + (t/is (= ["completed"] (get-outcomes job-id))) + (t/testing "a terminal job gets no second end event" + (t/is (= 0 (jobs/complete cfg :job-id job-id :result {:value 2}))) + (t/is (= ["completed"] (get-outcomes job-id)))))) + +(t/deftest fail-records-an-end-event-and-a-structured-error + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/is (= 1 (jobs/fail cfg job-id test-error))) + (t/is (= ["failed"] (get-outcomes job-id))) + (t/testing "the error decodes back to the map we passed" + (t/is (= test-error (jobs/decode-job-error (:error (jobs/get-job cfg job-id)))))) + (t/testing "the event carries no error, only the outcome" + (t/is (= {:outcome "failed"} (:payload (last (get-events job-id)))))) + (t/testing "a terminal job gets no second end event" + (t/is (= 0 (jobs/fail cfg job-id test-error))) + (t/is (= ["failed"] (get-outcomes job-id)))))) + +(t/deftest cancel-records-an-end-event + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (t/is (= 1 (jobs/cancel cfg job-id))) + (t/is (= ["cancelled"] (get-outcomes job-id))) + (t/testing "a terminal job gets no second end event" + (t/is (= 0 (jobs/cancel cfg job-id))) + (t/is (= ["cancelled"] (get-outcomes job-id)))))) + +(t/deftest aborted-job-is-terminal-for-all-writers + "An aborted job (system-side terminal set by the orphan sweep) cannot be + moved by any lifecycle writer: first-terminal-wins, with no event and no + second transition." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg) + at (:scheduled-at (jobs/get-job cfg job-id))] + (th/db-update! :job {:status "aborted" + :error (db/json jobs/orphan-error)} + {:id job-id}) + (t/is (zero? (jobs/claim cfg job-id at))) + (t/is (zero? (jobs/retry-job cfg job-id 1 (ct/now) test-error :backoff))) + (t/is (zero? (jobs/complete cfg :job-id job-id :result {:v 1}))) + (t/is (zero? (jobs/fail cfg job-id test-error))) + (t/is (zero? (jobs/cancel cfg job-id))) + (t/is (= "aborted" (:status (jobs/get-job cfg job-id)))) + (t/is (= ["start"] (get-kinds job-id)) + "only the claim start event, no end event from any writer"))) + +(defn- with-failing-event + "Run `f` with the event insert forced to fail, the way a database that + rejects the row would. Everything the transition writes must go back + with the transaction that carried it." + [f] + (with-redefs [jobs/insert-event (fn [& _] + (throw (ex-info "event insert failed" {})))] + (f))) + +(defn- job-columns + "Every column a transition can move, decoded, so a before/after + comparison is a real one." + [cfg job-id] + (let [row (jobs/get-job cfg job-id)] + (cond-> (select-keys row [:status :modified-at :completed-at :retry-num + :scheduled-at :resource-id :result :error]) + (:error row) (update :error db/decode-json-pgobject) + (:result row) (update :result db/decode-json-pgobject)))) + +(t/deftest a-failing-event-write-rolls-back-every-writer + (let [cfg (make-cfg (get-job-defs)) + unchanged + (fn [job-id before] + (t/is (= before (job-columns cfg job-id)) + "no column the transition touches survived") + (t/is (not-any? #(= "end" %) (get-kinds job-id)) + "and no event of the failed transition was left behind"))] + + (t/testing "claim" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + at (:scheduled-at (jobs/get-job cfg job-id)) + before (job-columns cfg job-id)] + (with-failing-event #(t/is (thrown? Exception (jobs/claim cfg job-id at)))) + (unchanged job-id before) + (t/is (= [] (get-kinds job-id))))) + + (t/testing "retry-job" + (let [job-id (mk-running cfg) + before (job-columns cfg job-id)] + (with-failing-event + #(t/is (thrown? Exception + (jobs/retry-job cfg job-id 1 (ct/in-past {:minutes 1}) + test-error :backoff)))) + (unchanged job-id before) + (t/is (= ["start"] (get-kinds job-id))))) + + (t/testing "complete" + (let [job-id (mk-running cfg) + before (job-columns cfg job-id)] + (with-failing-event + #(t/is (thrown? Exception + (jobs/complete cfg :job-id job-id :result {:v 1})))) + (unchanged job-id before) + (t/is (= ["start"] (get-kinds job-id))))) + + (t/testing "fail" + (let [job-id (mk-running cfg) + before (job-columns cfg job-id)] + (with-failing-event #(t/is (thrown? Exception (jobs/fail cfg job-id test-error)))) + (unchanged job-id before) + (t/is (= ["start"] (get-kinds job-id))))) + + (t/testing "cancel" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + before (job-columns cfg job-id)] + (with-failing-event #(t/is (thrown? Exception (jobs/cancel cfg job-id)))) + (unchanged job-id before) + (t/is (= [] (get-kinds job-id))))) + + (t/testing "a resource a failed completion associated is undone with it" + (let [job-id (mk-running cfg) + resource-id (uuid/next)] + (th/db-insert! :storage-object {:id resource-id :backend "test"}) + (with-failing-event + #(t/is (thrown? Exception + (jobs/complete cfg :job-id job-id :result {:v 1} + :resource-id resource-id)))) + (let [row (jobs/get-job cfg job-id)] + (t/is (nil? (:resource-id row))) + (t/is (= "running" (:status row)))))))) + +(t/deftest complete-associates-a-resource-id-once + (let [cfg (make-cfg (get-job-defs)) + resource-id (uuid/next)] + (th/db-insert! :storage-object {:id resource-id :backend "test"}) + + (t/testing "a job without resource gets one at completion" + (let [job-id (mk-running cfg)] + (t/is (= 1 (jobs/complete cfg :job-id job-id + :result {:v 1} + :resource-id resource-id))) + (t/is (= resource-id (:resource-id (jobs/get-job cfg job-id)))))) + + (t/testing "completing without a resource leaves the column untouched" + (let [job-id (mk-running cfg)] + (t/is (= 1 (jobs/complete cfg :job-id job-id :result {:v 1}))) + (t/is (nil? (:resource-id (jobs/get-job cfg job-id)))))) + + (t/testing "a job that already has a resource refuses a second one" + (let [job-id (mk-running cfg)] + ;; a job that already carries a resource, as submit left it + (th/db-update! :job {:resource-id resource-id} {:id job-id}) + (t/is (thrown-with-msg? Exception #"already has a resource" + (jobs/complete cfg + :job-id job-id + :result {:v 1} + :resource-id (uuid/next)))) + (t/testing "and the job is still running" + (t/is (= "running" (:status (jobs/get-job cfg job-id))))))) + + (t/testing "a resource that is not a uuid is rejected before any write" + (let [job-id (mk-running cfg)] + (t/is (thrown-with-msg? Exception #"must be a uuid" + (jobs/complete cfg + :job-id job-id + :result {:v 1} + :resource-id "nope"))) + (t/is (= "running" (:status (jobs/get-job cfg job-id)))))))) + +(t/deftest job-error-schema-is-shared-and-validated + (t/testing "type and code are keywords so they decode back from JSON" + (t/is (= {:type :internal :code :orphan :hint "gone"} + (jobs/check-job-error {:type :internal :code :orphan :hint "gone"})))) + + (t/testing "a missing key is rejected" + (t/is (thrown? Exception (jobs/check-job-error {:type :internal :code :orphan}))) + (t/is (thrown? Exception (jobs/check-job-error {:type :internal + :code "orphan" + :hint "gone"})))) + + (t/testing "extra details are allowed" + (t/is (= {:type :internal :code :orphan :hint "gone" :attempt 3} + (jobs/check-job-error {:type :internal + :code :orphan + :hint "gone" + :attempt 3})))) + + (t/testing "fail validates before writing" + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/is (thrown? Exception (jobs/fail cfg job-id {:code :orphan}))) + (t/is (= "running" (:status (jobs/get-job cfg job-id)))) + (t/is (= ["start"] (get-kinds job-id))))) + + (t/testing "the orphan error is a valid job error" + (t/is (= jobs/orphan-error (jobs/check-job-error jobs/orphan-error))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; CONTENTION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; The safety of the writers rests on two things: a conditional UPDATE +;; that only the first caller can win, and a row lock the loser waits +;; on. Both are only meaningful when two callers arrive at once, so the +;; tests below put real threads in front of them. + +(defn- race + "Run every thunk on a thread of its own and return what each returned, + in the order they were given. They all wait on the same gate, so they + contend for whatever they share instead of running one after another." + [& thunks] + (let [gate (promise) + results (mapv (fn [f] + (future + @gate + (f))) + thunks)] + (deliver gate true) + (mapv deref results))) + +(t/deftest the-first-terminal-transition-wins + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/testing "a completion and a failure of the same job, at the same time" + (let [[completed failed] (race #(jobs/complete cfg :job-id job-id :result {:v 1}) + #(jobs/fail cfg job-id test-error))] + (t/is (= #{0 1} (set [completed failed])) + "one transition is applied and the other affects no row")) + + (t/testing "and the durable history agrees with the row that won" + (let [kinds (get-kinds job-id) + outcomes (get-outcomes job-id) + row (jobs/get-job cfg job-id)] + (t/is (= ["start" "end"] kinds) + "exactly one end event, written by whoever won") + (t/is (= 1 (count outcomes))) + (t/is (= (first outcomes) (:status row)) + "the stored outcome is the status of the row")))))) + +(t/deftest the-first-claim-wins + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + at (:scheduled-at (jobs/get-job cfg job-id))] + (t/testing "two workers claim the same advertised job at the same time" + (let [[a b] (race #(jobs/claim cfg job-id at) + #(jobs/claim cfg job-id at))] + (t/is (= [0 1] (sort [a b]))) + (t/is (= "running" (:status (jobs/get-job cfg job-id)))) + (t/is (= ["start"] (get-kinds job-id)) + "a job is started once however many workers ask for it"))))) + +(t/deftest a-report-never-lands-on-a-terminal-job + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/testing "a progress report and a completion of the same job, at once" + (let [[_writes completed] (race #(jobs/heartbeat cfg :job-id job-id + :progress {:current 1} + ::jobs/force? true) + #(jobs/complete cfg :job-id job-id :result {:v 1}))] + (t/is (= 1 completed) "the completion always wins, it owns the row") + (t/testing "the report either lost the race or was stored before the end" + (let [kinds (get-kinds job-id)] + (t/is (= "start" (first kinds))) + (t/is (= "end" (last kinds)) + "the end is always the last event, so no report came after it") + (t/is (<= 2 (count kinds) 3) + "at most the one report that made it in before the end")))) + (t/testing "a job that ended is not resurrected by a late report" + (let [before (get-kinds job-id)] + (t/is (zero? (jobs/heartbeat cfg :job-id job-id + :progress {:current 2} + ::jobs/force? true))) + (t/is (= before (get-kinds job-id)) "the late report stored nothing")))))) + +(t/deftest progress-event-reaches-the-cfg-of-its-own-callback + "The event helper needs the msgbus and the metrics from the cfg, and it + must find them on the map that `db/tx-run!` hands to the callback: the + writers pass the whole cfg, not a rebuilt one. A cfg missing either + dependency cannot produce a published event, so the publication is + what proves the cfg arrived intact." + (let [cfg (make-cfg (get-job-defs)) + profile-id (:id (th/create-profile* 1)) + messages (atom []) + job-cfg (assoc cfg ::mbus/msgbus (fake-msgbus messages)) + job-id (jobs/submit job-cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/profile-id profile-id})] + (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id + :progress {:current 1}))) + (t/testing "the event was stored" + (t/is (= [{:current 1}] (get-progresss job-id)))) + (t/testing "and the msgbus on that cfg was reachable from the callback" + (t/is (= 1 (count @messages))) + (t/is (= "progress" (:kind (:message (first @messages)))))))) diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index f8011c8b34..0172ec06d5 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -12,6 +12,7 @@ [app.config :as cf] [app.db :as db] [app.http.client :as-alias http] + [app.jobs :as jobs] [app.setup :as-alias setup] [backend-tests.helpers :as th] [clojure.test :as t] @@ -180,7 +181,10 @@ client (ig/init-key :app.nitrate/client {::http/client (Object.) ::setup/shared-keys shared-keys}) - handler (ig/init-key :app.loggers.audit.archive-task/handler + ;; The archive job is a job-def: the handler closes over the + ;; cfg it was given at init and takes a nil context, because + ;; an in-process invocation has no job row to describe. + job-def (ig/init-key :app.loggers.audit.archive-task/job-def {::db/pool (:app.db/pool th/*system*) ::setup/shared-keys shared-keys ::http/client (:app.http.client/client th/*system*) @@ -203,7 +207,7 @@ :context (db/tjson {}) :tracked-at (ct/now) :created-at (ct/now)})] - (handler {:props {:uri archive-uri}}) + ((::jobs/handler job-def) nil {:uri archive-uri}) (t/is (= 1 (count @nitrate-bodies))) (let [body (json/decode (first @nitrate-bodies) :key-fn json/read-kebab-key) event (first (:events body))] diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index ece038fef7..fd3a1bd5bf 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -6,9 +6,12 @@ (ns backend-tests.loggers-webhooks-test (:require + [app.common.time :as ct] + [app.common.transit :as transit] [app.common.uuid :as uuid] [app.db :as db] [app.http :as http] + [app.jobs :as jobs] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] @@ -18,28 +21,50 @@ (t/use-fixtures :each th/database-reset) (t/deftest process-event-handler-with-no-webhooks - (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (with-mocks [submit-mock {:target 'app.jobs/submit :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) res (th/run-task! :process-webhook-event - {:type "command" - :name "create-project" - :props {:team-id (:default-team-id prof)}})] + {:event-blob + (transit/encode-str + {:type "command" + :name "create-project" + :props {:team-id (:default-team-id prof)}})})] (t/is (= 0 (:call-count @submit-mock))) (t/is (nil? res))))) (t/deftest process-event-handler - (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (with-mocks [submit-mock {:target 'app.jobs/submit :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) whk (th/create-webhook* {:team-id (:default-team-id prof)}) res (th/run-task! :process-webhook-event - {:type "command" - :name "create-project" - :props {:team-id (:default-team-id prof)}})] + {:event-blob + (transit/encode-str + {:type "command" + :name "create-project" + :props {:team-id (:default-team-id prof)}})})] (t/is (= 1 (:call-count @submit-mock))) (t/is (nil? res))))) +(t/deftest process-event-submits-consumed-config-subset + (with-mocks [submit-mock {:target 'app.jobs/submit :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + whk (th/create-webhook* {:team-id (:default-team-id prof)})] + (th/run-task! :process-webhook-event + {:event-blob + (transit/encode-str + {:type "command" + :name "create-project" + :props {:team-id (:default-team-id prof)}})}) + (t/is (= 1 (:call-count @submit-mock))) + (let [options (second (:call-args @submit-mock)) + config (get-in options [::jobs/params :config])] + (t/is (= #{:id :uri :mtype} (set (keys config)))) + (t/is (= (:id whk) (:id config))) + (t/is (= (:uri whk) (:uri config))) + (t/is (= (:mtype whk) (:mtype config))))))) + (t/deftest run-webhook-handler-1 (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] (let [prof (th/create-profile* 1 {:is-active true}) @@ -48,7 +73,7 @@ :name "create-project" :props {:team-id (:default-team-id prof)}} res (th/run-task! :run-webhook - {:event evt + {:event (transit/encode-str evt) :config whk})] (t/is (= 1 (:call-count @http-mock))) @@ -70,7 +95,7 @@ :name "create-project" :props {:team-id (:default-team-id prof)}} res (th/run-task! :run-webhook - {:event evt + {:event (transit/encode-str evt) :config whk})] (t/is (= 1 (:call-count @http-mock))) @@ -88,11 +113,11 @@ ;; RUN 2 times more (th/run-task! :run-webhook - {:event evt + {:event (transit/encode-str evt) :config whk}) (th/run-task! :run-webhook - {:event evt + {:event (transit/encode-str evt) :config whk}) @@ -105,3 +130,64 @@ (t/is (= "unexpected-status:400" (:error-code whk'))) (t/is (= 3 (:error-count whk'))) (t/is (false? (:is-active whk'))))))) + +(t/deftest webhook-json-round-trip-preserves-uuid-types + "Integration test: drives process-webhook-event through the real pipeline + (submit → decode → handler) to verify uuid types survive: the JSON hop + carries only the opaque transit blob, and the handler decodes the typed + event from it." + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] + (let [prof (th/create-profile* 1 {:is-active true}) + whk (th/create-webhook* {:team-id (:default-team-id prof)}) + evt {:type "command" + :name "create-project" + :props {:team-id (:default-team-id prof)}}] + + ;; Submit the job through the real pipeline (this creates a job row) + (th/run-task! :process-webhook-event + {:event-blob (transit/encode-str evt)}) + + ;; Now run the pending jobs (simulates dispatcher + runner) + ;; This will decode the params from JSON and invoke the handler + (th/run-pending-jobs) + + ;; The handler should have been invoked and created a webhook_delivery row + ;; If uuid types were lost, the lookup would fail with a type error + (let [rows (th/db-query :webhook-delivery {:webhook-id (:id whk)})] + (t/is (= 1 (count rows))) + (t/is (nil? (-> rows first :error-code)))) + + ;; Refresh webhook + (let [whk' (th/db-get :webhook {:id (:id whk)})] + (t/is (nil? (:error-code whk'))))))) + +(t/deftest webhook-transit-round-trip-preserves-uuid-types + "The run-webhook event travels as the original transit blob inside the + JSON job props, so transit deliveries keep instant/UUID types (no wire + change vs the legacy worker), whatever shape the event has." + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] + (let [prof (th/create-profile* 1 {:is-active true}) + whk (th/create-webhook* {:team-id (:default-team-id prof) + :mtype "application/transit+json"}) + evt {:type "command" + :name "create-project" + :created-at (ct/now) + :tracked-at (ct/now) + :props {:team-id (:default-team-id prof) + :comment-id (uuid/next)}}] + + ;; through the real pipeline: process submits run-webhook, the + ;; runner decodes it from JSON and delivers it + (th/run-task! :process-webhook-event + {:event-blob (transit/encode-str evt)}) + (th/run-pending-jobs) + + (t/is (= 1 (:call-count @http-mock))) + (let [req (second (:call-args @http-mock)) + event (transit/decode-str (:body req))] + (t/is (= "application/transit+json" (get-in req [:headers "content-type"]))) + (t/is (uuid? (get-in event [:props :team-id]))) + (t/is (uuid? (get-in event [:props :comment-id]))) + (t/is (inst? (:created-at event))) + (t/is (inst? (:tracked-at event))))))) + diff --git a/backend/test/backend_tests/metrics_test.clj b/backend/test/backend_tests/metrics_test.clj index 5365ce1932..0b08855c7d 100644 --- a/backend/test/backend_tests/metrics_test.clj +++ b/backend/test/backend_tests/metrics_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.metrics-test (:require + [app.common.exceptions :as ex] [app.metrics :as mtx] [app.metrics.definition :as-alias mdef] [clojure.test :as t] @@ -93,6 +94,23 @@ (get-collector [_ _] collector))] (t/is (true? (mtx/run! instance :id :x :inc 1 :labels ["ok"]))))) +(t/deftest instance-resolves-the-metrics-from-a-cfg + ;; the resolver is a map read and nothing more: it hands back the very + ;; instance, not a copy and not a wrapper + (let [metrics (ig/init-key :app.metrics/metrics {:default {}})] + (t/is (identical? metrics (mtx/instance {::mtx/metrics metrics}))))) + +(t/deftest instance-raises-on-a-cfg-without-metrics + ;; A missing instance is a wiring bug, and the resolver is the one place + ;; that says so: a module that resolves it at the top of a write sees the + ;; failure before anything is persisted. + (let [cause (try + (mtx/instance {}) + (catch Throwable e e))] + (t/is (some? cause)) + (t/is (= :missing-metrics (:code (ex-data cause)))) + (t/is (re-find #"::mtx/metrics" (:hint (ex-data cause)))))) + (t/deftest definitions-schema-accepts-all-consumed-keys (t/is (true? (valid-definitions? {:test-timing diff --git a/backend/test/backend_tests/migrations_test.clj b/backend/test/backend_tests/migrations_test.clj new file mode 100644 index 0000000000..12597a4968 --- /dev/null +++ b/backend/test/backend_tests/migrations_test.clj @@ -0,0 +1,218 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.migrations-test + (:require + [app.db :as db] + [app.migrations :as migrations] + [app.util.migrations :as mg] + [backend-tests.helpers :as th] + [clojure.spec.alpha :as s] + [clojure.string :as str] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) + +(t/deftest migrations-registry-satisfies-steps-spec + (t/testing "every entry is a {:name :fn} step (guards against stray forms)" + (t/is (s/valid? ::mg/steps migrations/migrations) + (s/explain-str ::mg/steps migrations/migrations)))) + +(defn- table-columns + [table] + (->> (th/db-exec! ["SELECT column_name FROM information_schema.columns + WHERE table_schema='public' AND table_name=?" table]) + (map :column-name) + set)) + +(defn- table-indexes + [table] + (->> (th/db-exec! ["SELECT indexname FROM pg_indexes + WHERE schemaname='public' AND tablename=?" table]) + (map :indexname) + set)) + +(defn- table-foreign-keys + [table] + (->> (th/db-exec! ["SELECT conname, confdeltype FROM pg_constraint + WHERE contype='f' AND conrelid = ?::regclass" table]) + (map (fn [{:keys [conname confdeltype]}] + [conname confdeltype])) + set)) + +(defn- deferrable-foreign-keys + "Names of the deferrable foreign keys of a table." + [table] + (->> (th/db-exec! ["SELECT conname FROM pg_constraint + WHERE contype='f' AND condeferrable AND conrelid = ?::regclass" table]) + (map :conname) + set)) + +(t/deftest job-table-exists-with-expected-columns + (t/is (= #{; dispatch/lifecycle columns + "id" "name" "tenant" "queue" "label" "priority" "scheduled_at" + "retry_num" "max_retries" "status" "created_at" "modified_at" + "started_at" "completed_at" "params" + ; optional ledger columns + "profile_id" "error" "result" + "resource_id" "expires_at"} + (table-columns "job")))) + +(t/deftest job-tenant-column-is-not-nullable + (t/testing "every job belongs to an instance, so the column is required" + (t/is (= "NO" + (:is-nullable (th/db-exec-one! ["SELECT is_nullable + FROM information_schema.columns + WHERE table_name = ? + AND column_name = ?" + "job" "tenant"])))))) + +(t/deftest job-queue-stores-the-bare-queue-name + (let [job-id (th/mk-uuid "job-bare-queue")] + (th/db-insert! :job {:id job-id + :name "test" + :tenant "acme" + :queue "webhooks"}) + (try + (t/is (= "acme" (:tenant (th/db-get :job {:id job-id})))) + (t/is (= "webhooks" (:queue (th/db-get :job {:id job-id})))) + (t/testing "the tenant is not smuggled inside the queue" + (t/is (not (str/includes? (:queue (th/db-get :job {:id job-id})) "acme")))) + (finally + (th/db-force-delete :job {:id job-id}))))) + +(t/deftest job-table-has-no-target-nor-progress-columns + (t/testing "target and job.progress are gone: progress lives in job_event" + (t/is (empty? (filter #{"target" "progress"} (table-columns "job")))))) + +(t/deftest job-event-table-exists-with-expected-columns + (t/is (= #{"id" "job_id" "kind" "payload" "created_at"} + (table-columns "job_event")))) + +(t/deftest job-event-kind-check-rejects-unknown-kinds + (let [job-id (th/mk-uuid "job-event-check")] + (th/db-insert! :job {:id job-id + :name "test" + :tenant "acme" + :queue "default"}) + (try + (t/testing "the four lifecycle kinds are accepted" + (doseq [kind ["start" "progress" "retry" "end"]] + (t/is (some? (th/db-insert! :job-event {:job-id job-id + :kind kind}))))) + (t/testing "any other kind is rejected" + (t/is (thrown? Exception + (th/db-insert! :job-event {:job-id job-id + :kind "unknown"})))) + (finally + (th/db-force-delete :job {:id job-id}))))) + +(t/deftest job-event-index-serves-the-history-read + (let [indexdef (:indexdef (th/db-exec-one! ["SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'job_event' + AND indexname = 'job_event__job_kind_created_idx'"]))] + (t/is (str/includes? indexdef "(job_id, kind, created_at DESC, id DESC)")))) + +(t/deftest job-event-cascades-on-job-deletion + (let [job-id (th/mk-uuid "job-event-cascade")] + (th/db-insert! :job {:id job-id :name "test" :tenant "acme" :queue "default"}) + (th/db-insert! :job-event {:job-id job-id :kind "progress"}) + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event WHERE job_id = ?" job-id])))) + (th/db-force-delete :job {:id job-id}) + (t/is (= 0 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event WHERE job_id = ?" job-id])))))) + +(t/deftest job-event-foreign-key-is-deferrable + (t/testing "every new job reference is DEFERRABLE" + (t/is (contains? (deferrable-foreign-keys "job_event") "job_event_job_id_fkey")))) + +(t/deftest job-table-has-expected-indexes + (t/is (contains? (table-indexes "job") "job__dispatcher__idx")) + (t/is (contains? (table-indexes "job") "job__orphan__idx")) + (t/is (contains? (table-indexes "job") "job__profile__idx"))) + +(defn- indexdef + [index-name] + (:indexdef (th/db-exec-one! ["SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'job' + AND indexname = ?" index-name]))) + +(t/deftest job-dispatcher-index-serves-ordering + (let [def (indexdef "job__dispatcher__idx")] + (t/testing "tenant leads because the filter is an equality it can answer" + (t/is (str/includes? def "(tenant, priority DESC, scheduled_at)"))) + (t/testing "status only in the predicate, it is not a key column" + (t/is (str/includes? def "WHERE")) + (t/is (not (str/includes? def "(status, scheduled_at)")))) + (t/testing "queue does not lead: the dispatcher claims every queue of the tenant" + (t/is (not (str/includes? def "(tenant, queue")))))) + +(t/deftest job-sweep-indexes-are-tenant-scoped + (t/testing "orphan sweep" + (t/is (str/includes? (indexdef "job__orphan__idx") "(tenant, modified_at)"))) + (t/testing "reschedule of lost scheduled rows" + (t/is (str/includes? (indexdef "job__scheduled__idx") "(tenant, scheduled_at)"))) + (t/testing "cron no-overlap check and submit dedupe" + (t/is (str/includes? (indexdef "job__name_label__idx") "(tenant, name, queue, label)")))) + +(t/deftest job-table-has-sweep-path-indexes + (t/testing "jobs-GC expiration scan" + (t/is (contains? (table-indexes "job") "job__expires__idx"))) + (t/testing "jobs-GC retention scan" + (t/is (contains? (table-indexes "job") "job__retention__idx")))) + +(t/deftest job-table-has-expected-foreign-keys + (t/testing "profile_id keeps profile deletion explicit (no action)" + (t/is (contains? (table-foreign-keys "job") + ["job_profile_id_fkey" "a"]))) + + (t/testing "resource_id is set to null when the storage object is deleted" + (t/is (contains? (table-foreign-keys "job") + ["job_resource_id_fkey" "n"])))) + +(t/deftest job-status-check-constraint-rejects-unknown-statuses + (t/is (thrown? Exception + (th/db-insert! :job {:id (th/mk-uuid "job-check") + :name "test" + :tenant "acme" + :queue "default" + :status "unknown"})))) + +(t/deftest job-status-check-constraint-accepts-known-statuses + (doseq [status ["new" "scheduled" "running" "retry" + "completed" "failed" "cancelled" "aborted"]] + (th/db-insert! :job {:id (th/mk-uuid "job-status" status) + :name "test" + :tenant "acme" + :queue "default" + :status status}) + (let [{:keys [status priority retry-num max-retries params scheduled-at + created-at modified-at]} + (-> (th/db-get :job {:id (th/mk-uuid "job-status" status)} :status + :priority :retry-num :max-retries :params :scheduled-at + :created-at :modified-at) + (update :params db/decode-json-pgobject))] + (t/is (= status status)) + (t/is (= 100 priority)) + (t/is (= 0 retry-num)) + (t/is (= 3 max-retries)) + (t/is (= {} params)) + (t/is (some? scheduled-at)) + (t/is (some? created-at)) + (t/is (some? modified-at)) + (th/db-force-delete :job {:id (th/mk-uuid "job-status" status)})))) + +(t/deftest job-has-only-the-deletion-protection-trigger + (let [triggers (->> (th/db-exec! ["SELECT tgname FROM pg_trigger + WHERE tgrelid = ?::regclass + AND NOT tgisinternal" "job"]) + (map :tgname) + set)] + (t/testing "no modified-at trigger: the application updates it" + (t/is (not (contains? triggers "job__modified_at__tgr")))) + (t/testing "deletion protection is the only trigger on job" + (t/is (= #{"deletion_protection__tgr"} triggers))))) diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj index 64fc64b8e1..f25dbcb9c1 100644 --- a/backend/test/backend_tests/rpc_demo_test.clj +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -9,8 +9,8 @@ [app.auth :as auth] [app.common.time :as ct] [app.config :as cf] + [app.jobs :as jobs] [app.rpc.commands.profile :as profile] - [app.worker :as wrk] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -80,8 +80,8 @@ (t/deftest create-demo-profile-uses-global-delay-by-default (with-redefs [cf/flags (conj cf/flags :demo-users)] (let [captured (atom nil)] - (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/delay]}] - (reset! captured {:task task :delay delay}))] + (with-redefs [jobs/submit (fn [_cfg {:keys [::jobs/name ::jobs/delay]}] + (reset! captured {:task name :delay delay}))] (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})] (t/is (nil? error)) (t/is (some? (:email result))) @@ -91,8 +91,8 @@ (t/deftest create-demo-profile-accepts-short-expires-in (with-redefs [cf/flags (conj cf/flags :demo-users)] (let [captured (atom nil)] - (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/delay]}] - (reset! captured {:task task :delay delay}))] + (with-redefs [jobs/submit (fn [_cfg {:keys [::jobs/name ::jobs/delay]}] + (reset! captured {:task name :delay delay}))] (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile :expires-in "10m"})] (t/is (nil? error)) diff --git a/backend/test/backend_tests/rpc_feedback_test.clj b/backend/test/backend_tests/rpc_feedback_test.clj index b4a6ae650d..c9147e6649 100644 --- a/backend/test/backend_tests/rpc_feedback_test.clj +++ b/backend/test/backend_tests/rpc_feedback_test.clj @@ -7,9 +7,17 @@ (ns backend-tests.rpc-feedback-test (:require [app.common.schema :as sm] + [app.config :as cf] + [app.db :as db] + [app.email :as eml] + [app.rpc :as-alias rpc] [app.rpc.commands.feedback :as feedback] + [backend-tests.helpers :as th] [clojure.test :as t])) +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + (t/deftest send-user-feedback-schema-validation (let [schema feedback/schema:send-user-feedback] @@ -37,3 +45,20 @@ :content "Test content" :error-report (apply str (repeat 1048577 "x"))}] (t/is (not (sm/valid? schema params))))))) + +(t/deftest send-user-feedback-reaches-send-with-connection + (with-redefs [cf/flags (conj cf/flags :user-feedback) + cf/config (assoc cf/config :user-feedback-destination "fb@example.com")] + (let [profile (th/create-profile* 1 {}) + captured (atom nil)] + (with-redefs [eml/send (fn [cfg params] + (reset! captured {:cfg cfg :params params}) + nil)] + (let [{:keys [error]} (th/command! {::th/type :send-user-feedback + ::rpc/profile-id (:id profile) + :subject "s" + :content "c"})] + (t/is (nil? error)))) + (t/testing "send! is reached with a caller connection" + (t/is (some? (:cfg @captured))) + (t/is (some? (::db/conn (:cfg @captured)))))))) diff --git a/backend/test/backend_tests/rpc_file_snapshot_test.clj b/backend/test/backend_tests/rpc_file_snapshot_test.clj index aed2b5ce39..a04e7c407b 100644 --- a/backend/test/backend_tests/rpc_file_snapshot_test.clj +++ b/backend/test/backend_tests/rpc_file_snapshot_test.clj @@ -131,7 +131,7 @@ (t/is (= (:code data) :system-snapshots-cant-be-deleted))))) ;; this will run pending task triggered by deleting user snapshot - (th/run-pending-tasks!) + (th/run-pending-jobs) (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index e2f7f631a0..3104e30139 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -717,7 +717,7 @@ ;; Now that objects-gc have deleted the object thumbnail lets ;; execute the touched-gc task (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! "storage-gc-touched" {}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:freeze res)))) ;; check file media objects @@ -1083,7 +1083,7 @@ out (th/command! params)] (t/is (nil? (:error out)))) - (th/run-pending-tasks!) + (th/run-pending-jobs) ;; query the list of files after soft deletion (let [data {::th/type :get-project-files @@ -1528,7 +1528,7 @@ (t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))) ;; The FileGC task will schedule an inner taskq - (th/run-pending-tasks!) + (th/run-pending-jobs) (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] @@ -2218,6 +2218,9 @@ :id (:id library)}) ;; The task swallows absorption errors, so verify the persisted result. + ;; Absorption runs inside the job, and the job only gets the + ;; components of its own cfg, so this also needs + ;; `:app.tasks.delete-object/job-def` to carry `::sto/storage`. (let [deleted (db/get* th/*pool* :file {:id (:id library)} {::db/remove-deleted false}) out (th/command! {::th/type :get-file @@ -2377,7 +2380,7 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out))) - (th/run-pending-tasks!) + (th/run-pending-jobs) ;; get deleted files (let [data {::th/type :get-team-deleted-files diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 5273180c15..46206af06f 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -86,7 +86,7 @@ ;; run the task again (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! "storage-gc-touched" {}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res)))) (let [[row1 row2 :as rows] (th/db-query :file-tagged-object-thumbnail diff --git a/backend/test/backend_tests/rpc_management_jobs_test.clj b/backend/test/backend_tests/rpc_management_jobs_test.clj new file mode 100644 index 0000000000..d129e9687c --- /dev/null +++ b/backend/test/backend_tests/rpc_management_jobs_test.clj @@ -0,0 +1,418 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.rpc-management-jobs-test + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.metrics :as-alias mtx] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) + +(defn- make-cfg [] + {::db/pool th/*pool* + ::mtx/metrics (get th/*system* :app.metrics/metrics)}) + +(defn- test-fixture [next] + (th/database-reset next)) + +(t/use-fixtures :each test-fixture) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- mk-job + [{:keys [name status params scheduled-at] + :or {name "media-process" + status "new" + params {:x 1} + scheduled-at (ct/now)}}] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name name + :tenant (cf/get :tenant) + :queue "media" + :params (db/json params) + :priority 100 + :max-retries 3 + :retry-num 0 + :status status + :scheduled-at scheduled-at + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + +(defn- get-row + [id] + (let [row (th/db-get :job {:id id} + :status :result :error + :started-at :completed-at :modified-at)] + (reduce (fn [row key] + (update row key #(cond-> % (db/pgobject? %) db/decode-json-pgobject))) + row + [:result :error]))) + +(def ^:private test-error + {:type :internal + :code :boom + :hint "boom"}) + +(defn- get-progresss + "Progress events of a job, oldest first, with decoded payloads." + [id] + (->> (th/db-exec! ["SELECT payload FROM job_event + WHERE job_id = ? AND kind = 'progress' + ORDER BY created_at ASC, id ASC" + id]) + (mapv #(db/decode-json-pgobject (:payload %))))) + +(defn- mgmt + [type params] + (th/management-command! (assoc params ::th/type type))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest claim-job-returns-run-with-name-and-params + (let [scheduled-at (ct/now) + job-id (mk-job {:scheduled-at scheduled-at + :params {:command :process :file-id (str (uuid/next))}}) + out (mgmt :claim-job {:job-id job-id + :scheduled-at scheduled-at})] + (t/is (nil? (:error out))) + (t/is (= {:action :run + :name "media-process" + :params {:command "process" + :file-id (get-in out [:result :params :file-id])}} + (:result out))) + (let [row (get-row job-id)] + (t/is (= "running" (:status row))) + (t/is (some? (:started-at row)))))) + +(t/deftest claim-job-skips-on-stale-scheduled-at + (let [job-id (mk-job {}) + out (mgmt :claim-job {:job-id job-id + :scheduled-at (ct/in-future {:minutes 5})})] + (t/is (nil? (:error out))) + (t/is (= {:action :skip} (:result out))) + (t/is (= "new" (:status (get-row job-id)))))) + +(t/deftest claim-job-skips-terminal-and-cancelled-rows + (let [completed-id (mk-job {:status "completed"}) + cancelled-id (mk-job {:status "cancelled"}) + aborted-id (mk-job {:status "aborted"})] + (t/is (= {:action :skip} + (:result (mgmt :claim-job {:job-id completed-id + :scheduled-at (ct/now)})))) + (t/is (= {:action :skip} + (:result (mgmt :claim-job {:job-id cancelled-id + :scheduled-at (ct/now)})))) + (t/is (= {:action :skip} + (:result (mgmt :claim-job {:job-id aborted-id + :scheduled-at (ct/now)})))) + (t/is (= "completed" (:status (get-row completed-id)))) + (t/is (= "cancelled" (:status (get-row cancelled-id)))) + (t/is (= "aborted" (:status (get-row aborted-id)))))) + +(t/deftest claim-job-skips-missing-row + (t/is (= {:action :skip} + (:result (mgmt :claim-job {:job-id (uuid/next) + :scheduled-at (ct/now)}))))) + +(t/deftest claim-job-validates-params + (let [out (mgmt :claim-job {:job-id (uuid/next)})] + (t/is (= :validation (th/ex-type (:error out)))))) + +(t/deftest report-job-progress-appends-a-progress-event + (let [job-id (mk-job {}) + _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + out (mgmt :report-job-progress {:job-id job-id + :progress {:total 100 :current 50}})] + (t/is (nil? (:error out))) + (t/is (= {:action :run} (:result out))) + (t/is (= [{:total 100 :current 50}] (get-progresss job-id))) + (t/testing "the job row has no progress column anymore" + (t/is (not (contains? (get-row job-id) :progress)))))) + +(t/deftest report-job-progress-noop-on-terminal-row + (let [job-id (mk-job {:status "completed"}) + out (mgmt :report-job-progress {:job-id job-id + :progress {:total 100 :current 100}})] + (t/is (nil? (:error out))) + (t/is (= {:action :skip} (:result out))) + (t/is (= [] (get-progresss job-id))))) + +(t/deftest report-job-progress-persists-rapid-reports + (let [job-id (mk-job {}) + _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + _ (mgmt :report-job-progress {:job-id job-id + :progress {:total 10 :current 3}}) + _ (mgmt :report-job-progress {:job-id job-id + :progress {:total 10 :current 4}})] + (t/testing "the second immediate report is not throttled away" + (t/is (= [{:total 10 :current 3} + {:total 10 :current 4}] + (get-progresss job-id)))))) + +(t/deftest report-job-progress-validates-the-payload + (let [job-id (mk-job {}) + _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at)))] + (t/testing "current is mandatory" + (t/is (= :validation (th/ex-type (:error (mgmt :report-job-progress + {:job-id job-id + :progress {:total 10}})))))) + (t/testing "no extra key is accepted" + (t/is (= :validation (th/ex-type (:error (mgmt :report-job-progress + {:job-id job-id + :progress {:current 1 + :message "boom"}})))))) + (t/is (= [] (get-progresss job-id))))) + +(t/deftest complete-job-marks-completed-with-result + (let [cfg (make-cfg) + job-id (mk-job {}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + out (mgmt :complete-job {:job-id job-id + :result {:value 42}})] + (t/is (nil? (:error out))) + (t/is (= {:action :run} (:result out))) + (let [row (get-row job-id)] + (t/is (= "completed" (:status row))) + (t/is (= {:value 42} (:result row))) + (t/is (some? (:completed-at row)))))) + +(t/deftest complete-job-accepts-nested-result + (let [cfg (make-cfg) + job-id (mk-job {}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + result {:value 42 :nested {:items [1 2 {:three 3}] :ok true}} + out (mgmt :complete-job {:job-id job-id :result result})] + (t/is (nil? (:error out))) + (let [row (get-row job-id)] + (t/is (= "completed" (:status row))) + (t/is (= result (:result row)))))) + +(t/deftest complete-job-without-result-stores-null + (let [cfg (make-cfg) + job-id (mk-job {}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + out (mgmt :complete-job {:job-id job-id :result nil})] + (t/is (nil? (:error out))) + (let [row (get-row job-id)] + (t/is (= "completed" (:status row))) + (t/is (nil? (:result row)))))) + +(t/deftest complete-job-with-unserializable-result-stores-null + (let [cfg (make-cfg) + job-id (mk-job {}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at)))] + (jobs/complete cfg :job-id job-id :result (Object.)) + (let [row (get-row job-id)] + (t/is (= "completed" (:status row))) + (t/is (nil? (:result row)))))) + +(t/deftest complete-job-with-unserializable-result-logs-job-name + (let [cfg (make-cfg) + job-id (mk-job {:name "media-process"}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + captured (atom nil)] + (with-redefs [l/emit-log (fn [props _cause _ctx _logger _level _sync?] + (reset! captured (into {} @props)))] + (jobs/complete cfg :job-id job-id :result (Object.))) + (let [row (get-row job-id)] + (t/is (= "completed" (:status row))) + (t/is (nil? (:result row))) + (t/testing "the serialization warning carries the job name" + (t/is (= "media-process" (:job-name @captured))))))) + +(t/deftest complete-and-fail-clean-throttle-state + (let [cfg (make-cfg) + job-id1 (mk-job {}) + job-id2 (mk-job {})] + (jobs/claim cfg job-id1 (:scheduled-at (th/db-get :job {:id job-id1} :id :scheduled-at))) + (jobs/claim cfg job-id2 (:scheduled-at (th/db-get :job {:id job-id2} :id :scheduled-at))) + (jobs/heartbeat cfg :job-id job-id1) + (jobs/heartbeat cfg :job-id job-id2 :progress {:current 1}) + (jobs/complete cfg :job-id job-id1) + (jobs/fail cfg job-id2 test-error) + (t/is (not (contains? @@#'jobs/heartbeats job-id1))) + (t/is (not (contains? @@#'jobs/progresses job-id2))))) + +(t/deftest complete-and-fail-respect-first-terminal-wins + (let [cfg (make-cfg) + running-id (mk-job {}) + orphan-id (mk-job {:status "aborted"}) + _ (jobs/claim cfg running-id + (:scheduled-at (th/db-get :job {:id running-id} :id :scheduled-at)))] + ;; a running job completes; a later complete/fail on the same row is a no-op + (t/is (= 1 (jobs/complete cfg :job-id running-id :result {:value 1}))) + (t/is (= 0 (jobs/complete cfg :job-id running-id :result {:value 2}))) + (t/is (= 0 (jobs/fail cfg running-id test-error))) + (t/is (= {:value 1} (:result (get-row running-id)))) + + ;; an orphan (aborted by the dispatcher) is never overwritten + (t/is (= 0 (jobs/complete cfg :job-id orphan-id :result {:value 3}))) + (t/is (= 0 (jobs/fail cfg orphan-id test-error))))) + +(t/deftest fail-job-marks-failed-with-error + (let [cfg (make-cfg) + job-id (mk-job {}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + out (mgmt :fail-job {:job-id job-id + :error {:type :internal + :code "processing-error" + :hint "bad image"}})] + (t/is (nil? (:error out))) + (let [row (get-row job-id)] + (t/is (= "failed" (:status row))) + (t/is (= {:type "internal" + :code "processing-error" + :hint "bad image"} + (:error row)))))) + +(t/deftest fail-job-validates-params + (let [job-id (mk-job {})] + (t/is (= :validation (th/ex-type (:error (mgmt :fail-job {:job-id job-id}))))))) + +(t/deftest terminal-writers-report-skip-on-terminal-rows + (let [done-id (mk-job {:status "completed"})] + (t/testing "complete on a terminal row reports skip" + (let [out (mgmt :complete-job {:job-id done-id :result {:x 1}})] + (t/is (nil? (:error out))) + (t/is (= {:action :skip} (:result out))))) + (t/testing "fail on a terminal row reports skip" + (let [out (mgmt :fail-job {:job-id done-id + :error {:type :internal + :code "processing-error" + :hint "bad image"}})] + (t/is (nil? (:error out))) + (t/is (= {:action :skip} (:result out))))) + (t/testing "progress on a terminal row reports skip" + (let [out (mgmt :report-job-progress {:job-id done-id + :progress {:total 10 :current 4}})] + (t/is (nil? (:error out))) + (t/is (= {:action :skip} (:result out)))))) + (let [cfg (make-cfg) + job-id (mk-job {}) + _ (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at)))] + (t/testing "live paths report run" + (t/is (= {:action :run} + (:result (mgmt :report-job-progress {:job-id job-id + :progress {:total 10 :current 4}})))) + (t/is (= {:action :run} + (:result (mgmt :complete-job {:job-id job-id + :result {:x 1}}))))))) + +(t/deftest complete-job-associates-a-resource-id + (let [cfg (make-cfg) + resource-id (uuid/next)] + (th/db-insert! :storage-object {:id resource-id :backend "test"}) + (let [job-id (mk-job {})] + (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + (t/is (nil? (:error (mgmt :complete-job {:job-id job-id + :result {:v 1} + :resource-id resource-id})))) + (t/is (= resource-id (:resource-id (get-row job-id))))))) + +(t/deftest complete-job-refuses-a-second-resource-id + (let [cfg (make-cfg) + first (uuid/next) + second (uuid/next)] + (doseq [id [first second]] + (th/db-insert! :storage-object {:id id :backend "test"})) + (let [job-id (mk-job {})] + (th/db-update! :job {:resource-id first} {:id job-id}) + (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + (let [out (mgmt :complete-job {:job-id job-id + :result {:v 1} + :resource-id second})] + (t/is (= :validation (th/ex-type (:error out))))) + (t/testing "the job keeps the resource it had and is still running" + (let [row (get-row job-id)] + (t/is (= first (:resource-id row))) + (t/is (= "running" (:status row)))))))) + +(t/deftest complete-job-on-a-finished-job-skips-instead-of-refusing-the-resource + (let [cfg (make-cfg) + first (uuid/next) + second (uuid/next)] + (doseq [id [first second]] + (th/db-insert! :storage-object {:id id :backend "test"})) + (let [job-id (mk-job {})] + (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + (t/is (= {:action :run} + (:result (mgmt :complete-job {:job-id job-id + :result {:v 1} + :resource-id first})))) + + (t/testing "the job is already finished, so there is no resource left to refuse" + (let [out (mgmt :complete-job {:job-id job-id + :result {:v 2} + :resource-id second})] + (t/is (nil? (:error out))) + (t/is (= {:action :skip} (:result out))))) + + (t/testing "the row keeps the resource it was given and no event is added" + (let [row (get-row job-id)] + (t/is (= first (:resource-id row))) + (t/is (= "completed" (:status row))) + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event + WHERE job_id = ? AND kind = 'end'" + job-id]))))))))) + +(t/deftest complete-job-validates-the-resource-id + (let [cfg (make-cfg) + job-id (mk-job {})] + (jobs/claim cfg job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + (t/is (= :validation + (th/ex-type (:error (mgmt :complete-job {:job-id job-id + :result {:v 1} + :resource-id "not-a-uuid"}))))))) + +(t/deftest fail-job-validates-the-error-shape + (let [cfg (make-cfg) + run (fn [error] + (let [job-id (mk-job {})] + (jobs/claim cfg job-id + (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) + [(mgmt :fail-job {:job-id job-id :error error}) job-id]))] + + (t/testing "hint is required" + (t/is (= :validation + (th/ex-type (:error (first (run {:type :internal + :code :processing-error}))))))) + + (t/testing "a string code over the wire decodes into the keyword" + (let [[out job-id] (run {:type :internal + :code "processing-error" + :hint "bad image"})] + (t/is (nil? (:error out))) + (t/is (= {:type :internal :code :processing-error :hint "bad image"} + (jobs/decode-job-error (:error (get-row job-id))))))) + + (t/testing "a keyword error is stored and decodes back untouched" + (let [[out job-id] (run {:type :internal + :code :processing-error + :hint "bad image"})] + (t/is (nil? (:error out))) + (t/is (= {:type :internal :code :processing-error :hint "bad image"} + (jobs/decode-job-error (:error (get-row job-id))))))) + + (t/testing "worker-defined extra details are kept" + (let [[out job-id] (run {:type :internal + :code :processing-error + :hint "bad image" + :attempt 3})] + (t/is (nil? (:error out))) + (t/is (= 3 (:attempt (jobs/decode-job-error (:error (get-row job-id)))))))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 9dd6ae9c26..c00930857c 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -14,12 +14,12 @@ [app.db :as db] [app.email :as eml] [app.http :as-alias http] + [app.jobs :as jobs] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.util.ssrf :as ssrf] - [app.worker :as wrk] [backend-tests.helpers :as th] [clojure.set :as set] [clojure.test :as t] @@ -53,7 +53,7 @@ (t/deftest create-and-update-organization-invitations-audit-props (let [owner-id-ref (atom nil)] - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} audit-mock {:target 'app.loggers.audit/submit :return nil} nitrate-mock {:target 'app.nitrate/call :return (fn [_cfg method params] @@ -102,7 +102,7 @@ (t/deftest invite-to-organization-rejects-non-owner (let [organization-summary-ref (atom nil)] - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} nitrate-mock {:target 'app.nitrate/call :return (fn [_cfg method _params] (when (= method :get-organization-summary) @@ -130,7 +130,7 @@ (t/is (not (:called? @email-mock))))))) (t/deftest invite-to-organization-rejects-unknown-organization - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 105 {:is-active true}) organization-id (uuid/random) @@ -149,7 +149,7 @@ (t/deftest invite-to-organization-uses-authoritative-branding (let [organization-summary-ref (atom nil)] - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} nitrate-mock {:target 'app.nitrate/call :return (fn [_cfg method _params] (when (= method :get-organization-summary) @@ -175,7 +175,7 @@ :logo "https://evil.example/logo.png" :avatar-bg-url "https://evil.example/avatar.svg" :sso-active false}}) - email-params (first (:call-args @email-mock)) + email-params (second (:call-args @email-mock)) organization (:organization email-params)] (t/is (th/success? out)) (t/is (= "Trusted Organization" (:name organization))) @@ -488,7 +488,7 @@ @organization-summary-ref nil))} ;; --- Worker mock: capture delete-task submission --- - wrk-mock {:target 'app.worker/submit! :return nil} + wrk-mock {:target 'app.jobs/submit :return nil} ;; --- Message bus mock: capture published events --- mbus-mock {:target 'app.msgbus/pub! :return nil}] @@ -587,7 +587,7 @@ :get-owned-organizations @owned-organizations-ref nil))} ;; --- Worker mock: capture delete-task submissions --- - wrk-mock {:target 'app.worker/submit! :return nil} + wrk-mock {:target 'app.jobs/submit :return nil} ;; --- Message bus mock: capture published events --- mbus-mock {:target 'app.msgbus/pub! :return nil}] @@ -1834,8 +1834,8 @@ nil)) nil))} ;; --- Email mock: capture sent emails --- - email-mock {:target 'app.email/send! - :return (fn [params] (swap! sent conj params) nil)}] + email-mock {:target 'app.email/send + :return (fn [_cfg params] (swap! sent conj params) nil)}] ;; --- Setup: create profiles, team, organization-summary --- (let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"}) @@ -1912,7 +1912,7 @@ :organization-id (uuid/random) :updated-props false :announce-activation false}] - (with-redefs [eml/send! (fn [params] (swap! sent conj params))] + (with-redefs [eml/send (fn [_cfg params] (swap! sent conj params))] (th/management-command! params)) (t/is (empty? @sent)))) @@ -2099,32 +2099,32 @@ (t/deftest send-renewal-email-falls-back-to-profile-fullname-when-name-is-nil ;; `nil` user-name means "no override": the RPC must look up the ;; account owner's real name instead of sending a blank greeting. - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true :fullname "Nitrate User"}) out (th/management-command! (send-renewal-email-params profile nil))] (t/is (th/success? out)) - (let [[params] (:call-args @email-mock)] + (let [[_cfg params] (:call-args @email-mock)] (t/is (= "Nitrate User" (:user-name params))))))) (t/deftest send-renewal-email-keeps-explicit-empty-name ;; An explicit "" means the caller deliberately wants no name shown ;; and must not be replaced by the profile's fullname. - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true :fullname "Nitrate User"}) out (th/management-command! (send-renewal-email-params profile ""))] (t/is (th/success? out)) - (let [[params] (:call-args @email-mock)] + (let [[_cfg params] (:call-args @email-mock)] (t/is (= "" (:user-name params))))))) (t/deftest send-renewal-email-treats-blank-name-as-empty ;; A blank name is trimmed and follows the same path as "": no name ;; is shown and the profile's fullname is not used. - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true :fullname "Nitrate User"}) out (th/management-command! (send-renewal-email-params profile " "))] (t/is (th/success? out)) - (let [[params] (:call-args @email-mock)] + (let [[_cfg params] (:call-args @email-mock)] (t/is (= "" (:user-name params))))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 1d279f7fda..b50d5dd8ac 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -1197,7 +1197,7 @@ :sso-active? true :set-team-params set-team-params}) teams/initialize-user-in-organization (fn [& _] nil) - eml/send! (fn [params] (swap! sent conj params))] + eml/send (fn [_cfg params] (swap! sent conj params))] (let [out (th/command! {::th/type :add-team-to-organization ::rpc/profile-id (:id owner) :team-id (:id team) @@ -1328,7 +1328,7 @@ :team-id (:id team) :sso-active? false}) teams/initialize-user-in-organization (fn [& _] nil) - eml/send! (fn [params] (swap! sent conj params))] + eml/send (fn [_cfg params] (swap! sent conj params))] (let [out (th/command! {::th/type :add-team-to-organization ::rpc/profile-id (:id owner) :team-id (:id team) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 1bfb54aeee..fbdef2f160 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -190,7 +190,7 @@ (t/is (nil? (:error out))) (t/is (= 1 (count (:result out))))) - (th/run-pending-tasks!) + (th/run-pending-jobs) (let [row (th/db-get :team {:id (:default-team-id prof)} @@ -357,7 +357,7 @@ (t/is (nil? (:error out))) (t/is (= 1 (count (:result out))))) - (th/run-pending-tasks!) + (th/run-pending-jobs) ;; execute permanent deletion task (let [result (th/run-task! :objects-gc {:min-age 0})] @@ -393,7 +393,7 @@ (t/is (nil? (:result out))) (t/is (nil? (:error out)))) - (th/run-pending-tasks!) + (th/run-pending-jobs) (let [rows (th/db-exec! ["select id,name,deleted_at from team where deleted_at is not null"])] (t/is (= 3 (count rows)))) @@ -509,7 +509,7 @@ (t/is (= "mtma" (:penpot/mtm-campaign props))))))) (t/deftest prepare-register-and-register-profile-2 - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [current-token (atom nil)] ;; PREPARE REGISTER (let [data {::th/type :prepare-register-profile @@ -563,7 +563,7 @@ (t/is (= 1 (:call-count @mock)))))))) (t/deftest prepare-register-and-register-profile-3 - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [current-token (atom nil)] ;; PREPARE REGISTER (let [data {::th/type :prepare-register-profile @@ -611,7 +611,7 @@ ;; When disable-email-verification is set and the profile is inactive ;; (e.g. created before the flag was set), re-registering should be ;; rejected with :email-already-exists. - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (with-redefs [app.config/flags #{:registration :login-with-password}] (let [current-token (atom nil)] ;; PREPARE REGISTER: first attempt (no profile exists yet) @@ -699,7 +699,7 @@ (t/deftest prepare-register-with-invitation-and-disabled-registration (with-redefs [app.config/flags #{:login-with-password :email-verification}] - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [email "invited@example.com" fixture (create-invitation-fixture email (ct/in-future "48h")) itoken (create-test-invitation-token fixture email) @@ -840,7 +840,7 @@ ;; verify-email mail with the invitation token EMBEDDED into the ;; verify-email JWE (so the team-invitation flow can resume after ;; the user clicks the email link). - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [itoken (tokens/generate th/*system* {:iss :team-invitation :exp (ct/in-future "48h") @@ -1181,7 +1181,7 @@ "prepare-register must not embed existing profile id of an anonymous caller")))) (t/deftest register-profile-with-invitation-must-not-take-over-existing-account - (with-mocks [_mock {:target 'app.email/send! :return nil}] + (with-mocks [_mock {:target 'app.email/send :return nil}] (let [;; Victim profile exists but is not yet active (e.g. registered ;; but has not clicked the verification link). This is the ;; remaining attack surface after fix 1b: `prepare-register` @@ -1287,7 +1287,7 @@ (t/is (true? (:is-active reloaded)))))) (t/deftest email-change-request - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile (th/create-profile* 1) pool (:app.db/pool th/*system*) data {::th/type :request-email-change @@ -1327,7 +1327,7 @@ (t/deftest email-change-request-without-smtp - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (with-redefs [app.config/flags #{}] (let [profile (th/create-profile* 1) pool (:app.db/pool th/*system*) @@ -1343,7 +1343,7 @@ (t/deftest request-profile-recovery - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active false}) profile2 (th/create-profile* 2 {:is-active true}) pool (:app.db/pool th/*system*) @@ -1401,7 +1401,7 @@ (t/deftest update-profile-password - (with-mocks [_ {:target 'app.email/send! :return nil}] + (with-mocks [_ {:target 'app.email/send :return nil}] (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) @@ -1413,7 +1413,7 @@ (t/deftest update-profile-password-bad-old-password - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) @@ -1700,7 +1700,7 @@ (t/is (th/ex-of-code? (:error out) :weak-password)))) (t/deftest update-profile-password-sends-notification - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) @@ -1710,14 +1710,14 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out))) (t/is (= 1 (:call-count @mock))) - (let [{:keys [::eml/factory :to :name]} (first (:call-args-list @mock))] + (let [[_cfg {:keys [::eml/factory :to :name]}] (first (:call-args-list @mock))] (t/is (= eml/password-changed factory)) (t/is (= (:email profile) to)) (t/is (= (:fullname profile) name)))))) (t/deftest update-profile-password-sends-notification-for-first-password - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile (th/create-profile* 1 {:password "!"}) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) @@ -1726,14 +1726,14 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out))) (t/is (= 1 (:call-count @mock))) - (let [{:keys [::eml/factory :to :name]} (first (:call-args-list @mock))] + (let [[_cfg {:keys [::eml/factory :to :name]}] (first (:call-args-list @mock))] (t/is (= eml/password-changed factory)) (t/is (= (:email profile) to)) (t/is (= (:fullname profile) name)))))) (t/deftest recover-profile-sends-notification - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile (th/create-profile* 1) token (tokens/generate th/*system* {:iss :password-recovery @@ -1745,7 +1745,7 @@ out (th/command! data)] (t/is (nil? (:error out))) (t/is (= 1 (:call-count @mock))) - (let [{:keys [::eml/factory :to :name]} (first (:call-args-list @mock))] + (let [[_cfg {:keys [::eml/factory :to :name]}] (first (:call-args-list @mock))] (t/is (= eml/password-changed factory)) (t/is (= (:email profile) to)) (t/is (= (:fullname profile) name)))))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 5f6a5d6543..11b3190164 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -27,7 +27,7 @@ (t/use-fixtures :each th/database-reset) (t/deftest create-team-invitations - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) profile2 (th/create-profile* 2 {:is-active true}) profile3 (th/create-profile* 3 {:is-active true :is-muted true}) @@ -106,7 +106,7 @@ (t/is (= :member-is-muted (:code edata)))))))) (t/deftest create-and-update-team-invitations-include-organization-props - (with-mocks [email-mock {:target 'app.email/send! :return nil} + (with-mocks [email-mock {:target 'app.email/send :return nil} audit-mock {:target 'app.loggers.audit/submit :return nil}] (let [owner (th/create-profile* 101 {:is-active true}) invitee (th/create-profile* 102 {:is-active true}) @@ -167,7 +167,7 @@ (t/is (false? (get-in create-plain [:props :invitee-already-organization-member]))))))) (t/deftest create-team-invitations-blacklisted-domain - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile1)}) data {::th/type :create-team-invitations @@ -207,7 +207,7 @@ (t/is (= 1 (:call-count @mock)))))))) (t/deftest create-team-invitations-with-request-access - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) requester (th/create-profile* 2 {:is-active true :email "requester@example.com"}) @@ -244,7 +244,7 @@ (t/deftest create-team-invitations-with-request-access-2 - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) requester (th/create-profile* 2 {:is-active true :email "requester@example.com"}) @@ -307,7 +307,7 @@ (t/deftest invitation-tokens - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) profile2 (th/create-profile* 2 {:is-active true}) @@ -619,7 +619,7 @@ (t/is (not-any? #(= "accept-organization-invitation" (:name %)) events)))))) (t/deftest create-team-invitations-with-email-verification-disabled - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) profile2 (th/create-profile* 2 {:is-active true}) profile3 (th/create-profile* 3 {:is-active true :is-muted true}) @@ -850,7 +850,7 @@ (t/is (= 1 (count result))) (t/is (= (:default-team-id profile1) (get-in result [0 :id]))))) - (th/run-pending-tasks!) + (th/run-pending-jobs) ;; run permanent deletion (should be noop) (let [result (th/run-task! :objects-gc {})] @@ -918,7 +918,7 @@ #_(th/print-result! out) (t/is (nil? (:error out)))) - (th/run-pending-tasks!) + (th/run-pending-jobs) (let [rows (th/db-exec! ["select * from team where id = ?" (:id team)])] (t/is (= 1 (count rows))) @@ -929,7 +929,7 @@ (t/is (= 7 (:processed result))))))) (t/deftest create-team-access-request - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) requester (th/create-profile* 3 {:is-active true :email "requester@bar.com"}) team (th/create-team* 1 {:profile-id (:id owner)}) @@ -976,7 +976,7 @@ (t/deftest create-team-access-request-owner-muted - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true :is-muted true :email "owner@bar.com"}) requester (th/create-profile* 2 {:is-active true :email "requester@bar.com"}) team (th/create-team* 1 {:profile-id (:id owner)}) @@ -995,7 +995,7 @@ (t/deftest create-team-access-request-requester-muted - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) requester (th/create-profile* 2 {:is-active true :is-muted true :email "requester@bar.com"}) team (th/create-team* 1 {:profile-id (:id owner)}) @@ -1021,7 +1021,7 @@ (t/deftest create-team-access-request-owner-bounce - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) requester (th/create-profile* 2 {:is-active true :email "requester@bar.com"}) team (th/create-team* 1 {:profile-id (:id owner)}) @@ -1049,7 +1049,7 @@ (t/is (= "private" (:email edata))))))) (t/deftest create-team-access-request-requester-bounce - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) requester (th/create-profile* 2 {:is-active true :email "requester@bar.com"}) team (th/create-team* 1 {:profile-id (:id owner)}) @@ -1106,7 +1106,7 @@ (t/is (th/success? out))))) (t/deftest create-team-invitations-email-cooldown - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile1)}) @@ -1240,7 +1240,7 @@ ;; --- T7-F-01: Role ceiling in team invitations --- (t/deftest admin-cannot-create-invitation-with-owner-role - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true}) admin (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id owner)})] @@ -1264,7 +1264,7 @@ (t/is (= 0 (:call-count @mock))))))) (t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true}) admin (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id owner)})] @@ -1287,7 +1287,7 @@ (t/is (= 0 (:call-count @mock))))))) (t/deftest admin-cannot-update-invitation-role-to-owner - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true}) admin (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id owner)})] @@ -1321,7 +1321,7 @@ (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)))))) (t/deftest owner-can-create-invitation-with-owner-role - (with-mocks [mock {:target 'app.email/send! :return nil}] + (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id owner)})] diff --git a/backend/test/backend_tests/storage_metadata_test.clj b/backend/test/backend_tests/storage_metadata_test.clj index 4efb4c80df..c622bdae4b 100644 --- a/backend/test/backend_tests/storage_metadata_test.clj +++ b/backend/test/backend_tests/storage_metadata_test.clj @@ -259,7 +259,8 @@ (t/deftest metadata-buckets-is-the-canonical-set (t/is (= #{"file-media-object" "team-font-variant" "file-object-thumbnail" "file-thumbnail" "profile" "organization" "tempfile" - "upload-session" "file-data" "file-data-fragment" "file-change"} + "upload-session" "job-resource" "file-data" "file-data-fragment" + "file-change"} stsch/metadata-buckets))) (t/deftest decode-metadata-returns-nil-for-nil diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index abdd8c1503..a49d5b4717 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -16,6 +16,7 @@ [app.rpc :as-alias rpc] [app.storage :as sto] [app.storage.fs :as-alias sto.fs] + [app.storage.gc-deleted :as gcd] [app.storage.impl :as impl] [app.storage.s3 :as-alias sto.s3] [app.storage.schema :as stsch] @@ -475,7 +476,7 @@ (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res))))) - ;; processed immediately with skip-delay + ;; processed immediately with skip-delay, bypassing min-age (binding [ct/*clock* (ct/fixed-clock now)] (let [res (th/run-task! :storage-gc-touched {:skip-delay true})] (t/is (= 0 (:freeze res))) @@ -639,6 +640,32 @@ (let [res (th/run-task! :storage-gc-deleted {})] (t/is (= 1 (:deleted res)))))) +(t/deftest storage-gc-deleted-commits-each-chunk-separately + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + ;; more than one chunk (chunk-size is 25); distinct contents so + ;; the storage layer does not deduplicate them into one row + ids (doall (for [i (range 26)] + (:id (sto/put-object! storage + {::sto/content (sto/content (str "content" i)) + :content-type "text/plain"}))))] + (t/is (= 26 (count ids))) + (th/db-exec! ["update storage_object set deleted_at = ?" (ct/now)]) + + (let [orig @#'gcd/process-chunk + calls (atom 0)] + (alter-var-root #'gcd/process-chunk + (constantly (fn [& args] + (when (= 2 (swap! calls inc)) + (throw (ex-info "boom" {}))) + (apply orig args)))) + (try + (t/is (thrown? Exception (th/run-task! :storage-gc-deleted {}))) + (t/testing "first chunk committed before the fault" + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM storage_object WHERE deleted_at IS NOT NULL"]))))) + (finally + (alter-var-root #'gcd/process-chunk (constantly orig))))))) + (t/deftest objects-gc-task-skip-delay (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) @@ -676,9 +703,10 @@ (let [res (th/run-task! :objects-gc {})] (t/is (= 0 (:processed res)))) - ;; with skip-delay it is processed immediately + ;; with skip-delay the future deleted row IS processed immediately (let [res (th/run-task! :objects-gc {:skip-delay true})] - (t/is (= 1 (:processed res))))))) + (t/is (= 1 (:processed res))) + (t/is (nil? (th/db-get :file-media-object {:id (:id result-1)}))))))) (t/deftest put-object-write-failure-leaves-pending-row (let [storage (-> (:app.storage/storage th/*system*) @@ -1236,3 +1264,67 @@ (t/is (= "boom" (ex-message (ex-cause ex))))) ;; one initial attempt plus max-retries (t/is (= 4 (:call-count @mock))))) + +(t/deftest touched-gc-job-resource-bucket + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + job-id (uuid/next) + object (sto/put-object! storage {::sto/content (sto/content "content") + :content-type "text/plain" + :bucket sto/job-resource-bucket})] + + ;; the object is created with the job-resource bucket in metadata + (t/is (= sto/job-resource-bucket (:bucket (meta object)))) + + ;; a live job row referencing the object keeps it frozen + (th/db-update! :storage-object {:touched-at (ct/now)} {:id (:id object)}) + (th/db-insert! :job {:id job-id + :name "test-job" + :tenant (cf/get :tenant) + :queue "default" + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status "running" + :resource-id (:id object) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 1 (:freeze res))) + (t/is (= 0 (:delete res))) + (t/is (nil? (:touched-at (th/db-get :storage-object {:id (:id object)} + :id :touched-at)))) + (t/is (nil? (:deleted-at (th/db-get :storage-object {:id (:id object)} + :id :deleted-at))))) + + ;; once the referencing row is gone, the touched object becomes + ;; eligible for deletion (the job table is deletion-protected) + (th/db-force-delete :job {:id job-id}) + (th/db-update! :storage-object {:touched-at (ct/now)} {:id (:id object)}) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 1 (:delete res))) + (t/is (nil? (:touched-at (th/db-get :storage-object {:id (:id object)} + :id :touched-at)))) + (t/is (some? (:deleted-at (th/db-get :storage-object + {:id (:id object)} + {::db/remove-deleted false}))))) + + ;; a touched object with no referencing job row at all is deleted too + (let [object (sto/put-object! storage {::sto/content (sto/content "content") + :content-type "text/plain" + :bucket sto/job-resource-bucket})] + (th/db-update! :storage-object {:touched-at (ct/now)} {:id (:id object)}) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (>= (:delete res) 1)) + (t/is (some? (:deleted-at (th/db-get :storage-object + {:id (:id object)} + {::db/remove-deleted false})))))))) diff --git a/backend/test/backend_tests/worker_cron_test.clj b/backend/test/backend_tests/worker_cron_test.clj new file mode 100644 index 0000000000..d8ab47209a --- /dev/null +++ b/backend/test/backend_tests/worker_cron_test.clj @@ -0,0 +1,201 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.worker-cron-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.util.cron :as ucron] + [app.worker.cron :as cron] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [integrant.core :as ig])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- get-job + [cfg job-id] + (db/get* cfg :job {:id job-id})) + +(defn- count-jobs + [cfg & {:keys [name tenant queue label]}] + (let [conditions (concat + (when name ["name = ?"]) + (when tenant ["tenant = ?"]) + (when queue ["queue = ?"]) + (when label ["label = ?"])) + sql (str "SELECT count(*) AS n FROM job" + (when (seq conditions) + (str " WHERE " (str/join " AND " conditions)))) + params (concat + (when name [name]) + (when tenant [tenant]) + (when queue [queue]) + (when label [label]))] + (-> (db/exec-one! cfg (into [sql] params)) + :n))) + +(defn- make-cfg + [] + (-> th/*system* + (assoc ::jobs/defs (get th/*system* :app.jobs/defs)) + (assoc ::db/pool (get th/*system* ::db/pool)))) + +(defn- get-cron-entries + [] + [{:id :session-gc-no-props + :job :session-gc + :cron "0 0 * * *"} + {:id :jobs-gc-with-props + :job :jobs-gc + :cron "0 0 * * *" + :props {:min-age 3600000}}]) + +(t/deftest cron-component-init-registers-and-schedules-entries + (let [defs (get th/*system* :app.jobs/defs) + entries [{:id :test-cron-init + :job :session-gc + :cron (ucron/cron "0 0 0 * * ?")}] + inst (ig/init-key :app.worker/cron + {:app.worker/entries entries + ::jobs/defs defs + ::db/pool th/*pool*})] + (try + (t/testing "init schedules futures" + (t/is (pos? (count @inst)))) + (t/testing "scheduled_task row was upserted" + (t/is (some? (th/db-get :scheduled-task {:id "test-cron-init"})))) + (finally + (ig/halt-key! :app.worker/cron inst))))) + +(t/deftest cron-submits-job-with-empty-params-when-entry-has-no-props + (let [cfg (make-cfg) + entries (get-cron-entries) + entry (first entries)] + + ;; Submit a cron job for an entry without :props + (let [job-id (cron/submit-cron-job cfg entry)] + ;; Job should be created + (t/is (some? job-id)) + + ;; Job should exist in DB + (let [row (get-job cfg job-id)] + (t/is (some? row)) + (t/is (= "session-gc" (:name row))) + (t/is (= "cron" (:queue row))) + (t/is (= (cf/get :tenant) (:tenant row))) + (t/is (= "session-gc-no-props" (:label row))) + (t/is (nil? (:profile-id row))) + + ;; Props should be empty map after decoding + (let [props (db/decode-json-pgobject (:params row))] + (t/is (= {} props))))))) + +(t/deftest cron-submits-job-with-props-when-entry-declares-them + (let [cfg (make-cfg) + entries (get-cron-entries) + entry (second entries)] + + ;; Submit a cron job for an entry with :props + (let [job-id (cron/submit-cron-job cfg entry)] + ;; Job should be created + (t/is (some? job-id)) + + ;; Job should exist in DB with the props + (let [row (get-job cfg job-id)] + (t/is (some? row)) + (t/is (= "jobs-gc" (:name row))) + (t/is (= "jobs-gc-with-props" (:label row))) + ;; Props should be decoded to the original map + (let [props (db/decode-json-pgobject (:params row))] + (t/is (= {:min-age 3600000} props))))))) + +(t/deftest cron-tick-submits-only-when-no-active-instance + (let [cfg (make-cfg) + ;; the entry keeps the canonical keyword task; only the id is a + ;; string, as normalized by the cron component init. The cron + ;; expression must be parsed: the tick reschedules itself in + ;; `finally` + entry {:id "session-gc-no-overlap" + :job :session-gc + :cron (ucron/cron "0 0 0 * * ?")}] + + ;; the tick claims this row; without it the tick silently skips + (th/db-insert! :scheduled-task {:id "session-gc-no-overlap" + :cron-expr "0 0 * * *"}) + + ;; execute-cron-task runs on a plain thread; join waits for the tick + (let [tick! (fn [] (.join ^Thread (#'cron/execute-cron-task cfg entry)))] + ;; first tick submits exactly one job + (tick!) + (t/is (= 1 (count-jobs cfg :name "session-gc" + :label "session-gc-no-overlap"))) + + ;; second tick with the first still active submits nothing + (tick!) + (t/is (= 1 (count-jobs cfg :name "session-gc" + :label "session-gc-no-overlap"))) + + ;; once the first reaches terminal, the next tick submits again + (th/db-update! :job {:status "completed"} {:label "session-gc-no-overlap"}) + (tick!) + (t/is (= 2 (count-jobs cfg :name "session-gc" + :label "session-gc-no-overlap")))))) + +(t/deftest cron-job-created-on-cron-queue-with-null-profile-id + (let [cfg (make-cfg) + entries (get-cron-entries) + entry (first entries)] + + (let [job-id (cron/submit-cron-job cfg entry) + row (get-job cfg job-id)] + + ;; the queue column is bare; the tenant lives in its own column + (t/is (= "cron" (:queue row))) + (t/is (= (cf/get :tenant) (:tenant row))) + + ;; Verify profile_id is NULL (system job) + (t/is (nil? (:profile-id row))) + + ;; Verify label is the entry id as string (without colon) + (t/is (= "session-gc-no-props" (:label row)))))) + +(t/deftest cron-tick-is-not-blocked-by-another-tenants-active-job + (let [cfg (make-cfg) + entry {:id "session-gc-shared-db" + :job :session-gc + :cron (ucron/cron "0 0 0 * * ?")}] + + (th/db-insert! :scheduled-task {:id "session-gc-shared-db" + :cron-expr "0 0 * * *"}) + ;; An active job with the same name and label, owned by another + ;; instance that shares this database. It runs in another tenant's + ;; queue, so it is none of this instance's business. + (th/db-insert! :job {:id (uuid/next) + :name "session-gc" + :tenant "other-tenant" + :queue "cron" + :label "session-gc-shared-db" + :status "running" + :params (db/json {}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (.join ^Thread (#'cron/execute-cron-task cfg entry)) + + (t/testing "this tenant still submits its own job" + (t/is (= 1 (count-jobs cfg :name "session-gc" + :tenant (cf/get :tenant) + :label "session-gc-shared-db")))) + (t/testing "and the other tenant's row is untouched" + (t/is (= "running" (:status (th/db-get :job {:name "session-gc" + :tenant "other-tenant"}))))))) diff --git a/backend/test/backend_tests/worker_dispatcher_test.clj b/backend/test/backend_tests/worker_dispatcher_test.clj new file mode 100644 index 0000000000..02064f2774 --- /dev/null +++ b/backend/test/backend_tests/worker_dispatcher_test.clj @@ -0,0 +1,329 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.worker-dispatcher-test + (:require + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.main :as main] + [app.metrics :as-alias mtx] + [app.metrics.definition :as-alias mdef] + [app.redis :as rds] + [app.worker :as wrk] + [app.worker.dispatcher :as wdisp] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.Collector$MetricFamilySamples + io.prometheus.client.Collector$MetricFamilySamples$Sample + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) + +(t/use-fixtures :once th/state-init) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- mk-cfg + [] + {::db/pool th/*pool* + ::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics) + ::wrk/tenant (cf/get :tenant) + ::batch-size 100 + ::lease (cf/get-jobs-lease) + ::timeout (ct/duration "10s")}) + +(defn- make-metrics [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:jobs-dispatcher-timing])})) + +(defn- make-orphan-metrics [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:jobs-orphaned :jobs-completed + :jobs-total-timing])})) + +(defn- counter-value [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(defn- histogram-sample-count [metrics labels] + (->> (enumeration-seq + (.metricFamilySamples ^io.prometheus.client.CollectorRegistry + (mtx/get-registry metrics))) + (mapcat (fn [^Collector$MetricFamilySamples family] + (.samples family))) + (filter (fn [^Collector$MetricFamilySamples$Sample sample] + (and (str/ends-with? (.-name sample) "_count") + (= labels (vec (.-labelValues sample)))))) + (map (fn [^Collector$MetricFamilySamples$Sample sample] + (long (.-value sample)))) + (reduce + 0))) + +(defn- mk-job + [{:keys [name queue status scheduled-at modified-at tenant] + :or {name "test-job" + queue "test" + tenant (cf/get :tenant) + status "new" + scheduled-at (ct/now) + modified-at (ct/now)}}] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name name + :tenant tenant + :queue queue + :params (db/json {}) + :priority 100 + :max-retries 3 + :retry-num 0 + :status status + :scheduled-at scheduled-at + :created-at (ct/now) + :modified-at modified-at}) + id)) + +(defn- get-row + [id] + (-> (th/db-get :job {:id id} :status :modified-at :scheduled-at :error) + (update :error jobs/decode-job-error) + (assoc :events (->> (th/db-exec! ["SELECT kind, payload FROM job_event + WHERE job_id = ? ORDER BY id" id]) + (mapv (fn [{:keys [kind payload]}] + {:kind kind + :payload (db/decode-json-pgobject payload)})))))) + +(defn- queue-key + [queue-name] + (wrk/queue-key (cf/get :tenant) queue-name)) + +(defn- test-fixture [next] + (th/database-reset + (fn [] + ;; clear the redis hand-off list so tests don't see stale payloads + (let [conn (rds/connect (mk-cfg))] + (try + (rds/del conn (queue-key "test")) + (finally + (rds/close conn))) + (next))))) + +(t/use-fixtures :each test-fixture) + +(defn- drain-queue + [queue-name] + (let [conn (rds/connect (mk-cfg))] + (try + (let [cmd (.-cmd conn) + res (.lrange ^io.lettuce.core.api.sync.RedisCommands + cmd (queue-key queue-name) 0 -1)] + (vec res)) + (finally + (rds/del conn (queue-key queue-name)) + (rds/close conn))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest dispatcher-claims-due-jobs-and-pushes-json-payload + (let [cfg (mk-cfg) + id (mk-job {}) + _ (wdisp/run-batch cfg) + row (get-row id) + key (queue-key "test")] + + (t/testing "claimed job is marked scheduled" + (t/is (= "scheduled" (:status row)))) + + (t/testing "payload is plain JSON [uuid, iso-8601]" + (let [payloads (drain-queue "test")] + (t/is (= 1 (count payloads))) + (let [[job-id scheduled-at :as payload] (json/decode (first payloads))] + (t/is (string? job-id)) + (t/is (= (str id) job-id)) + (t/is (string? scheduled-at)) + (t/testing "round-trip: inst-ms are equal after ct/inst" + (let [expected (:scheduled-at row) + actual (ct/inst scheduled-at)] + (t/is (= (inst-ms expected) (inst-ms actual)))))))))) + +(t/deftest dispatcher-claims-retry-jobs-too + (let [cfg (mk-cfg) + id (mk-job {:status "retry"})] + (wdisp/run-batch cfg) + (t/is (= "scheduled" (:status (get-row id)))))) + +(t/deftest dispatcher-does-not-claim-future-or-terminal-jobs + (let [cfg (mk-cfg) + future (mk-job {:scheduled-at (ct/plus (ct/now) + (ct/duration {:minutes 10}))}) + running (mk-job {:status "running"})] + (wdisp/run-batch cfg) + (t/is (= "new" (:status (get-row future)))) + (t/is (= "running" (:status (get-row running)))))) + +(t/deftest dispatcher-reschedules-lost-scheduled-jobs + (let [cfg (mk-cfg) + lost-id (mk-job {:status "scheduled" + :scheduled-at (ct/minus (ct/now) + (ct/duration {:minutes 6}))}) + fresh-id (mk-job {:status "scheduled"})] + (wdisp/run-batch cfg) + (let [lost (get-row lost-id) + fresh (get-row fresh-id)] + ;; the lost job is rescheduled to 'new' and claimed again in the + ;; same batch, so it ends as 'scheduled' with a recent scheduled_at + (t/is (= "scheduled" (:status lost))) + (t/is (> (inst-ms (:scheduled-at lost)) + (inst-ms (ct/minus (ct/now) + (ct/duration {:minutes 5}))))) + (t/testing "the rescheduled job was pushed again to the queue" + (t/is (= 1 (count (drain-queue "test")))) + (t/is (= "scheduled" (:status fresh))))))) + +(t/deftest dispatcher-marks-stale-running-jobs-as-aborted-by-lease + (let [cfg (mk-cfg) + stale (mk-job {:status "running" + :modified-at (ct/minus (ct/now) + (ct/plus (cf/get-jobs-lease) + (ct/duration {:minutes 1})))}) + fresh (mk-job {:status "running"})] + (wdisp/run-batch cfg) + (let [stale-row (get-row stale)] + (t/is (= "aborted" (:status stale-row))) + (t/testing "the error is the structured one, decoded back to keywords" + (t/is (= jobs/orphan-error (:error stale-row)))) + (t/testing "the sweep stores the end event with the aborted outcome" + (t/is (= [{:kind "end" :payload {:outcome "aborted"}}] + (:events stale-row)))) + (t/testing "an aborted job is terminal: it is never retried nor reclaimed" + (t/is (zero? (jobs/retry-job cfg + (:id stale-row) + 1 (ct/now) jobs/orphan-error :backoff))))) + + (t/testing "recent running job is not touched" + (t/is (= "running" (:status (get-row fresh))))))) + +(t/deftest dispatcher-orphan-sweep-records-orphan-and-terminal-metrics + (let [metrics (make-orphan-metrics) + cfg (assoc (mk-cfg) ::mtx/metrics metrics) + _ (mk-job {:status "running" + :modified-at (ct/minus (ct/now) + (ct/plus (cf/get-jobs-lease) + (ct/duration {:minutes 1})))})] + (wdisp/run-batch cfg) + (t/is (= 1.0 (counter-value metrics :jobs-orphaned ["test"]))) + (t/is (= 1.0 (counter-value metrics :jobs-completed + ["test-job" "test" "aborted"]))))) + +(t/deftest dispatcher-batch-without-pending-jobs-signals-wait + (let [cfg (mk-cfg)] + (t/is (= ::wdisp/wait (wdisp/run-batch cfg))))) + +(t/deftest dispatcher-failed-mark-leaves-no-orphan-payload + ;; Mark runs before push: when the mark fails, the push never runs, so + ;; a rolled-back batch must leave neither a marked row nor a payload. + (let [cfg (assoc (mk-cfg) ::wdisp/timeout (ct/duration {:millis 10})) + id (mk-job {}) + orig @#'wdisp/mark-as-scheduled + calls (atom 0)] + (alter-var-root #'wdisp/mark-as-scheduled + (constantly (fn [& args] + (when (= 1 (swap! calls inc)) + (throw (ex-info "boom" {}))) + (apply orig args)))) + (try + (wdisp/run-batch cfg) + (t/testing "failed mark rolls back with no payload pushed" + (t/is (= "new" (:status (get-row id)))) + (t/is (empty? (drain-queue "test")))) + (finally + (alter-var-root #'wdisp/mark-as-scheduled (constantly orig)))) + (t/testing "next batch delivers exactly once" + (wdisp/run-batch cfg) + (t/is (= "scheduled" (:status (get-row id)))) + (t/is (= 1 (count (drain-queue "test"))))))) + +(t/deftest dispatcher-records-one-redis-failure + (let [metrics (make-metrics) + cfg (assoc (mk-cfg) ::mtx/metrics metrics + ::wdisp/timeout (ct/duration {:millis 10})) + _ (mk-job {})] + (with-redefs [rds/rpush (fn [& _] (throw (ex-info "redis down" {})))] + (wdisp/run-batch cfg)) + (t/is (= 1 (histogram-sample-count metrics ["execution" "failed"]))) + (t/is (= 0 (histogram-sample-count metrics ["dispatch" "failed"]))))) + +(t/deftest dispatcher-push-failure-rolls-back-without-throwing + (let [cfg (assoc (mk-cfg) ::wdisp/timeout (ct/duration {:millis 10})) + id (mk-job {}) + orig @#'rds/rpush] + (alter-var-root #'rds/rpush + (constantly (fn [& _] (throw (ex-info "redis down" {}))))) + (try + ;; run-batch catches the failure (sleep path) instead of throwing; + ;; a throw would error this test by itself + (wdisp/run-batch cfg) + (t/testing "failed push rolls back the batch" + (t/is (= "new" (:status (get-row id)))) + (t/is (empty? (drain-queue "test")))) + (finally + (alter-var-root #'rds/rpush (constantly orig)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TENANT ISOLATION +;; A batch is this tenant's: it must not read, claim or kill the rows of +;; another instance sharing the same database. + +(t/deftest dispatcher-does-not-claim-another-tenants-jobs + (let [cfg (mk-cfg) + foreign (mk-job {:tenant "other-tenant"})] + (wdisp/run-batch cfg) + (t/testing "the other tenant's job is not claimed" + (t/is (= "new" (:status (get-row foreign))))) + (t/testing "and nothing was pushed to this tenant's Redis list" + (t/is (empty? (drain-queue "test")))))) + +(t/deftest dispatcher-does-not-reschedule-another-tenants-lost-jobs + (let [cfg (mk-cfg) + foreign (mk-job {:status "scheduled" + :tenant "other-tenant" + :scheduled-at (ct/minus (ct/now) + (ct/duration {:minutes 6}))})] + (wdisp/run-batch cfg) + (t/testing "the other tenant's scheduled job keeps its status and its date" + (let [row (get-row foreign)] + (t/is (= "scheduled" (:status row))) + (t/is (< (inst-ms (:scheduled-at row)) + (inst-ms (ct/minus (ct/now) + (ct/duration {:minutes 5}))))))))) + +(t/deftest dispatcher-does-not-orphan-another-tenants-running-jobs + (let [cfg (mk-cfg) + ;; older than any lease: this tenant's sweep would kill it + foreign (mk-job {:status "running" + :tenant "other-tenant" + :modified-at (ct/minus (ct/now) + (ct/plus (cf/get-jobs-lease) + (ct/duration {:minutes 1})))})] + (wdisp/run-batch cfg) + (t/testing "the other tenant's running job is still running" + (let [row (get-row foreign)] + (t/is (= "running" (:status row))) + (t/is (nil? (:error row))) + (t/is (= [] (:events row))))))) diff --git a/backend/test/backend_tests/worker_runner_test.clj b/backend/test/backend_tests/worker_runner_test.clj new file mode 100644 index 0000000000..415d444ab8 --- /dev/null +++ b/backend/test/backend_tests/worker_runner_test.clj @@ -0,0 +1,566 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.worker-runner-test + (:require + [app.common.json :as json] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.main :as main] + [app.metrics :as-alias mtx] + [app.metrics.definition :as-alias mdef] + [app.redis :as rds] + [app.worker :as wrk] + [app.worker.runner :as wrkr] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.Collector$MetricFamilySamples + io.prometheus.client.Collector$MetricFamilySamples$Sample)) + +(t/use-fixtures :once th/state-init) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def received (atom [])) + +(def received-contexts (atom [])) + +(defn echo-handler + "Handler implementation: the context comes from the job-def wrapper and + the params are what the runner decoded from the row." + [cfg context params] + (swap! received conj params) + (swap! received-contexts conj context) + params) + +(def schema:echo-params + [:map + [:object :keyword] + [:deleted-at ::ct/inst] + [:id ::sm/uuid]]) + +(defn- echo-job-def + "Job-def with the standard wrapper: [context params], with the component + deps closed over at init time." + [] + {::jobs/name :echo-runner + ::jobs/schema schema:echo-params + ::jobs/handler (fn [context params] + (echo-handler nil context params)) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params)}) + +(defn- get-defs + [] + {:echo-runner (echo-job-def)}) + +(defn- mk-cfg + [{:keys [defs] + :or {defs (get-defs)}}] + {::db/pool th/*pool* + ::rds/conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)}) + ::jobs/defs defs + ::mtx/metrics (get th/*system* :app.metrics/metrics) + ::wrkr/id "test-runner" + ::wrkr/queue "test" + ::wrk/tenant (cf/get :tenant) + ::wrkr/timeout (ct/duration "5s")}) + +(defn- make-metrics [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:jobs-queue-wait-timing + :jobs-execution-timing + :jobs-total-timing + :tasks-timing])})) + +(defn- histogram-sample-count [metrics id labels] + (->> (enumeration-seq + (.metricFamilySamples ^io.prometheus.client.CollectorRegistry + (mtx/get-registry metrics))) + (filter (fn [^Collector$MetricFamilySamples family] + (= (.-name family) (::mdef/name (main/default-metrics id))))) + (mapcat (fn [^Collector$MetricFamilySamples family] + (.samples family))) + (filter (fn [^Collector$MetricFamilySamples$Sample sample] + (and (str/ends-with? (.-name sample) "_count") + (= labels (vec (.-labelValues sample)))))) + (map (fn [^Collector$MetricFamilySamples$Sample sample] + (long (.-value sample)))) + (reduce + 0))) + +(defn- queue-key + [] + (wrk/queue-key (cf/get :tenant) "test")) + +(defn- clear-queue + [] + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (rds/del conn (queue-key)) + (finally + (rds/close conn))))) + +(defn- test-fixture [next] + (th/database-reset + (fn [] + (reset! received []) + (reset! received-contexts []) + (clear-queue) + (next)))) + +(t/use-fixtures :each test-fixture) + +(defn- mk-job + [{:keys [name status scheduled-at params max-retries label] + :or {name "echo-runner" + status "new" + ;; Valid echo params by default: direct inserts bypass the + ;; submit! contract, and the runner validates decoded params. + params {:object :snapshot + :deleted-at (ct/now) + :id (uuid/next)} + max-retries 3}}] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name name + :tenant (cf/get :tenant) + :label label + :queue "test" + :params (db/json params) + :priority 100 + :max-retries max-retries + :retry-num 0 + :status status + :scheduled-at scheduled-at + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + +(defn- push-payload + [job-id scheduled-at] + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (rds/rpush conn (queue-key) [(json/encode [(str job-id) + (ct/format-inst scheduled-at)])]) + (finally + (rds/close conn))))) + +(defn- get-row + [id] + (-> (th/db-get :job {:id id}) + (as-> row + (-> row + (update :error jobs/decode-job-error) + (update :result #(cond-> % (db/pgobject? %) db/decode-json-pgobject)))))) + +(defn- get-events + [id] + (->> (th/db-exec! ["SELECT kind, payload FROM job_event + WHERE job_id = ? ORDER BY id ASC" id]) + (mapv (fn [{:keys [kind payload]}] + {:kind kind :payload (db/decode-json-pgobject payload)})))) + +(defn- run-one + [cfg] + (@#'wrkr/run-worker-loop cfg)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest runner-executes-handler-with-decoded-typed-params + (let [params {:object :snapshot + :deleted-at (ct/now) + :id (uuid/next)} + scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at :params params})] + + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + + (t/testing "handler received the decoded params" + (t/is (= 1 (count @received))) + (let [params' (first @received)] + (t/is (keyword? (:object params'))) + (t/is (= :snapshot (:object params'))) + (t/is (uuid? (:id params'))) + (t/is (ct/inst? (:deleted-at params'))))) + + (t/testing "job row is completed with the handler result" + (let [row (get-row job-id)] + (t/is (= "completed" (:status row))) + (t/is (some? (:started-at row))) + (t/is (some? (:completed-at row))) + (t/is (nil? (:error row))))))) + +(t/deftest runner-builds-the-handler-context-from-the-row + (let [params {:object :snapshot + :deleted-at (ct/now) + :id (uuid/next)} + scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at + :params params + :label "runner-label"})] + + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + + (t/testing "the handler receives exactly the four context keys" + (t/is (= 1 (count @received-contexts))) + (let [context (first @received-contexts)] + (t/is (= #{:id :name :label :resource-id} (set (keys context)))) + (t/is (= job-id (:id context))) + (t/is (= "echo-runner" (:name context))) + (t/is (= "runner-label" (:label context))) + (t/is (nil? (:resource-id context))) + + (t/testing "and nothing of the row leaks into it" + (t/is (not (contains? context :params))) + (t/is (not (contains? context :status))) + (t/is (not (contains? context :result))) + (t/is (not (contains? context :retry-num))) + (t/is (not (contains? context :max-retries))) + (t/is (not (contains? context :attempt))) + (t/is (not (contains? context :queue)) + "the tenant-prefixed queue is a routing detail, not context")))))) + +(t/deftest runner-records-queue-wait-execution-and-total + (let [metrics (make-metrics) + cfg (assoc (mk-cfg {}) ::mtx/metrics metrics) + at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at at})] + (push-payload job-id at) + (run-one cfg) + (t/is (= 1 (histogram-sample-count metrics + :jobs-queue-wait-timing + ["echo-runner" "test"]))) + (t/is (= 1 (histogram-sample-count metrics + :jobs-execution-timing + ["echo-runner" "test"]))) + (t/is (= 1 (histogram-sample-count metrics + :jobs-total-timing + ["echo-runner" "test" "completed"]))) + (t/testing "the legacy histogram is still emitted, with its raw name label" + (t/is (= 1 (histogram-sample-count metrics + :tasks-timing + ["echo-runner"])))))) + +(t/deftest runner-skips-cancelled-jobs + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at}) + _ (th/db-update! :job {:status "cancelled"} {:id job-id})] + + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + + (t/testing "conditional claim found 0 rows: state untouched" + (let [row (get-row job-id)] + (t/is (= "cancelled" (:status row))) + (t/is (nil? (:started-at row))) + (t/is (nil? (:completed-at row))))) + (t/testing "handler never invoked" + (t/is (empty? @received))))) + +(t/deftest runner-retry-with-backoff-respects-max-retries + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at :max-retries 1}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + (throw (ex-info "transient failure" + {:type ::wrk/retry + :delay (ct/duration {:millis 1000})}))))}] + (push-payload job-id scheduled-at) + + (t/testing "first retry attempt" + (run-one (mk-cfg {:defs defs})) + (let [row (get-row job-id)] + (t/is (= "retry" (:status row))) + (t/is (= 1 (:retry-num row))) + (t/is (= {:type :retry + :code :failed + :hint "transient failure" + :ex-type "retry"} (:error row))) + (t/testing "scheduled_at respects the backoff delay" + (t/is (> (inst-ms (:scheduled-at row)) (inst-ms (ct/now))))))) + + (t/testing "second attempt exhausts max-retries and fails" + (push-payload job-id (:scheduled-at (get-row job-id))) + (run-one (mk-cfg {:defs defs})) + (let [row (get-row job-id)] + (t/is (= "failed" (:status row))) + (t/is (= 1 (:retry-num row))) + (t/is (= {:type :retry + :code :failed + :hint "transient failure" + :ex-type "retry"} (:error row))))))) + +(t/deftest runner-retry-with-millis-delay-schedules-backoff + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + (throw (ex-info "transient failure" + {:type ::wrk/retry + ;; plain Long literal, not a Duration + :delay 5000}))))}] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + (let [row (get-row job-id)] + (t/testing "Long millis delay is honored (int? covers Long)" + (t/is (= "retry" (:status row))) + (t/is (= 1 (:retry-num row))) + (t/testing "scheduled_at is ~5s in the future" + (let [delta (- (inst-ms (:scheduled-at row)) (inst-ms (ct/now)))] + (t/is (> delta 4000)) + (t/is (< delta 6000)))))))) + +(t/deftest runner-noop-retry-at-zero-schedules-base-delay + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + (throw (ex-info "noop" + {:type ::wrk/retry + :strategy ::wrk/noop + :delay 5000}))))}] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + (let [row (get-row job-id)] + (t/testing "noop retry schedules instead of NPEing on take 0" + (t/is (= "retry" (:status row))) + (t/is (= 0 (:retry-num row))) + (t/testing "scheduled_at is ~5s in the future" + (let [delta (- (inst-ms (:scheduled-at row)) (inst-ms (ct/now)))] + (t/is (> delta 4000)) + (t/is (< delta 6000)))))))) + +(t/deftest runner-unhandled-exception-fails-when-no-retries-left + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at :max-retries 0}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] (throw (ex-info "fatal" {}))))}] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + (let [row (get-row job-id)] + (t/is (= "failed" (:status row))) + (t/is (= {:type :internal + :code :failed + :hint "fatal" + :ex-type "ex-info"} (:error row)))))) + +(t/deftest runner-terminal-write-does-not-overwrite-orphan-failure + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + ;; simulate the dispatcher marking the + ;; running job as aborted while the handler + ;; is executing + (th/db-update! :job + {:status "aborted" + :error (db/json jobs/orphan-error)} + {:id jobs/*job-id*}) + :ok))}] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + + (let [row (get-row job-id)] + (t/testing "the orphan abort is preserved (first-terminal-wins)" + (t/is (= "aborted" (:status row))) + (t/is (= jobs/orphan-error (:error row))) + (t/is (nil? (:completed-at row))))))) + +(t/deftest runner-claim-requires-current-scheduled-at + (let [at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:status "scheduled" :scheduled-at at}) + stale (ct/minus at (ct/duration {:minutes 6}))] + (t/testing "stale scheduled_at claims nothing" + (t/is (zero? (#'wrkr/claim-job (mk-cfg {}) job-id stale))) + (t/is (= "scheduled" (:status (get-row job-id))))) + (t/testing "current scheduled_at claims the row" + (t/is (= 1 (#'wrkr/claim-job (mk-cfg {}) job-id at))) + (t/is (= "running" (:status (get-row job-id))))))) + +(t/deftest runner-skips-stale-payload-after-reschedule + (let [stale-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at stale-at}) + fresh-at (ct/plus stale-at (ct/duration {:minutes 6}))] + ;; dispatcher reschedules: new scheduled_at + fresh payload + (th/db-update! :job {:status "scheduled" :scheduled-at fresh-at} {:id job-id}) + + (t/testing "stale payload is skipped, handler never invoked" + (push-payload job-id stale-at) + (run-one (mk-cfg {})) + (t/is (empty? @received)) + (let [row (get-row job-id)] + (t/is (= "scheduled" (:status row))) + (t/is (nil? (:started-at row))))) + + (t/testing "fresh payload still executes" + (push-payload job-id fresh-at) + (run-one (mk-cfg {})) + (t/is (= 1 (count @received))) + (t/is (= "completed" (:status (get-row job-id))))))) + +(t/deftest runner-unknown-job-name-fails-fast + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:name "no-such-job" + :scheduled-at scheduled-at + :max-retries 3})] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + (let [row (get-row job-id)] + (t/testing "no retries burned on a permanently-unknown name" + (t/is (= "failed" (:status row))) + (t/is (= 0 (:retry-num row))) + (t/is (nil? (:completed-at row))) + (t/is (= "not-found" (:ex-type (:error row)))))))) + +(t/deftest runner-schema-violation-fails-fast-without-retry + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at + :max-retries 3 + :params {:object :snapshot + :deleted-at (ct/now) + :id "not-a-uuid"}})] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + (let [row (get-row job-id)] + (t/testing "permanent schema violation fails fast" + (t/is (= "failed" (:status row))) + (t/is (= 0 (:retry-num row))) + (t/is (nil? (:completed-at row))) + (t/is (= "assertion" (:ex-type (:error row)))))))) + +(t/deftest invoke-executes-handlers-in-process-with-decoded-params + (let [raw-params {:object "snapshot" + :deleted-at "2026-01-01T00:00:00Z" + :id (str (uuid/next))}] + (reset! received []) + (let [result (jobs/invoke (merge (mk-cfg {}) + {::jobs/name :echo-runner + ::jobs/params raw-params}))] + (t/is (= 1 (count @received))) + (let [params' (first @received)] + (t/is (keyword? (:object params'))) + (t/is (= :snapshot (:object params'))) + (t/is (uuid? (:id params'))) + (t/is (ct/inst? (:deleted-at params')))) + (t/testing "invoke! returns the handler result" + (t/is (= (first @received) result)))))) + +(t/deftest runner-skips-malformed-payloads + (let [conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)})] + (try + (rds/rpush conn (queue-key) ["not-json" + (json/encode ["not-a-uuid" "not-an-inst"])]) + (finally + (rds/close conn)))) + (t/testing "non-JSON payload is skipped, handler never invoked" + (run-one (mk-cfg {})) + (t/is (empty? @received))) + (t/testing "JSON payload with wrong shape is skipped too" + (run-one (mk-cfg {})) + (t/is (empty? @received)))) + +(t/deftest runner-reads-the-reserved-completion-envelope + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + resource-id (uuid/next) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + {:result {:business 1} + :resource-id resource-id}))}] + (th/db-insert! :storage-object {:id resource-id :backend "test"}) + (let [job-id (mk-job {:scheduled-at scheduled-at})] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + + (let [row (get-row job-id)] + (t/testing "the envelope splits: business result in result, resource on the row" + (t/is (= "completed" (:status row))) + (t/is (= {:business 1} (:result row))) + (t/is (= resource-id (:resource-id row)))) + + (t/testing "and the end event is still a plain outcome" + (t/is (= [{:kind "end" :payload {:outcome "completed"}}] + (drop 1 (get-events job-id))))))))) + +(t/deftest runner-records-the-lifecycle-events-of-a-successful-job + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at})] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + (t/is (= [{:kind "start" :payload {:attempt 1}} + {:kind "end" :payload {:outcome "completed"}}] + (get-events job-id))))) + +(t/deftest runner-records-a-retry-event-before-the-next-attempt + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at :max-retries 3}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + (throw (ex-info "transient" {:type ::wrk/retry + :delay (ct/duration {:millis 1})}))))}] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + (t/is (= [{:kind "start" :payload {:attempt 1}} + {:kind "retry" :payload {:attempt 2 :reason "backoff"}}] + (get-events job-id))))) + +(t/deftest runner-records-a-noop-retry-as-the-same-attempt + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at :max-retries 3}) + defs {:echo-runner + (assoc (echo-job-def) + ::jobs/handler + (fn [_context _params] + (throw (ex-info "transient" {:type ::wrk/retry + :delay (ct/duration {:millis 1}) + :strategy ::wrk/noop}))))}] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {:defs defs})) + (t/testing "a noop consumed no budget, so the attempt does not move" + (t/is (= [{:kind "start" :payload {:attempt 1}} + {:kind "retry" :payload {:attempt 1 :reason "noop"}}] + (get-events job-id))) + (t/is (= 0 (:retry-num (get-row job-id))))))) + +(t/deftest deleting-a-job-deletes-its-events + (let [scheduled-at (ct/truncate (ct/now) :millisecond) + job-id (mk-job {:scheduled-at scheduled-at})] + (push-payload job-id scheduled-at) + (run-one (mk-cfg {})) + (t/is (= 2 (count (get-events job-id)))) + (th/db-force-delete :job {:id job-id}) + (t/is (= [] (get-events job-id))))) diff --git a/backend/test/backend_tests/worker_test.clj b/backend/test/backend_tests/worker_test.clj new file mode 100644 index 0000000000..75f87be0c4 --- /dev/null +++ b/backend/test/backend_tests/worker_test.clj @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.worker-test + (:require + [app.common.schema :as sm] + [app.msgbus :as mbus] + [app.worker :as wrk] + [app.worker.cron :as wcron] + [app.worker.dispatcher :as wdisp] + [app.worker.runner :as wrkr] + [clojure.test :as t])) + +;; The dispatcher's push and the runner's pop agree on the Redis list +;; only through this helper, so the test helpers use it too. That +;; keeps producer and consumer in sync by construction, but it also +;; means no other test would catch a malformed shape. This test pins +;; the physical format instead. + +(t/deftest queue-key-pins-the-physical-redis-shape + (t/testing "tenant and queue compose the documented key" + (t/is (= "penpot.worker.queue:acme:default" + (wrk/queue-key "acme" :default)))) + (t/testing "a string queue gives the same key as a keyword one" + (t/is (= "penpot.worker.queue:acme:default" + (wrk/queue-key "acme" "default")))) + (t/testing "hyphens travel through untouched" + (t/is (= "penpot.worker.queue:my-tenant:daily-cleanup" + (wrk/queue-key "my-tenant" :daily-cleanup))))) + +;; Booting the backend asserts these integrant keys, and assert-key +;; compiles the schema first: a ref with no registered schema crashes +;; the boot with :malli.core/invalid-schema instead of asserting. The +;; component schemas lean on the shared registry (db pool, metrics, +;; redis client, msgbus, job defs), so the registry dependencies must +;; be loaded for them to compile, like the full system does. +(t/deftest worker-component-schemas-resolve-every-ref + (t/testing "cron, dispatcher and runner schemas compile" + (t/is (sm/schema? (sm/schema ::mbus/msgbus))) + (doseq [s [@#'wcron/schema:params + @#'wdisp/schema:dispatcher + @#'wrkr/schema:params]] + (t/is (sm/schema? (sm/schema s)))))) diff --git a/scripts/ci b/scripts/ci index cff93eca3a..4428b673f9 100755 --- a/scripts/ci +++ b/scripts/ci @@ -8,6 +8,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" LOG_DIR="$PROJECT_ROOT/.ci-logs" +# `clojure.core/assert` is elided at compile time when `*assert*` is false, +# and `app.config` only sets that var from these flags. Without this the +# backend suite runs with every assertion compiled out, silently. Appending +# keeps whatever flags the caller already exported. +export PENPOT_FLAGS="${PENPOT_FLAGS:+$PENPOT_FLAGS } enable-backend-asserts" + # Color codes RED='\033[0;31m' GREEN='\033[0;32m' From 8d8dff8de4591d675cd7435d8eb60b82a1432c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 6 Oct 2026 10:29:53 +0200 Subject: [PATCH 030/325] :bug: Fix dropping caret styles when pasting html text (#12108) --- .../app/main/data/workspace/clipboard.cljs | 3 +- .../ui/workspace/shapes/text/v3_editor.cljs | 28 ++++++++++++++----- frontend/src/app/util/text/clipboard.cljs | 11 ++++++++ .../util_text_clipboard_test.cljs | 11 ++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index fabf3e3fed..4d365f0bee 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1067,7 +1067,8 @@ (watch [_ state _] (let [[text content] (if (v3-html-paste? state) - (when-let [fragment (text-clipboard/html->fragment html)] + (when-let [fragment (some-> (text-clipboard/html->fragment html) + (text-clipboard/without-overrides))] [(text-paste/fragment->text fragment) (text-paste/fragment->content fragment (txt/get-default-text-attrs))]) (let [style (deref refs/workspace-clipboard-style) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 8ed1cb50fe..f3138a5238 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -107,11 +107,13 @@ (def ^:private max-paste-html-length 1000000) (defn- clipboard->fragment - "The paste fragment for `data`: its HTML when allowed and it has text, else its plain text." + "The paste fragment for `data`: its HTML when allowed and it has text, else its + plain text. Either way it is unstyled, so it adopts the caret style." [^js data html-paste?] (let [html (when html-paste? (.getData data "text/html"))] (or (when (< 0 (count html) max-paste-html-length) - (text-clipboard/html->fragment html)) + (some-> (text-clipboard/html->fragment html) + (text-clipboard/without-overrides))) (text-clipboard/text->fragment (.getData data "text/plain"))))) (defn- selection-start @@ -125,18 +127,32 @@ {:para focus-para :offset focus-offset}))) (defn- paste-fragment - "Insert the text of `fragment`, then restyle it with the fragment overrides." + "Insert the text of `fragment`, then restyle it with the fragment overrides or, + when it has none, with the pending caret style." [fragment] (let [shape-id (text-editor/text-editor-get-active-shape-id) - start (selection-start)] + start (selection-start) + styled? (text-paste/styled? fragment) + pending? (some? (text-editor/get-pending-caret-styles shape-id))] + (when styled? + (text-editor/clear-pending-caret-styles!)) (text-editor/text-editor-insert-text (text-paste/fragment->text fragment)) - (if (and (some? start) (text-paste/styled? fragment)) + (cond + (nil? start) + (sync-wasm-text-editor-content!) + + styled? (do ;; Sync first so the cached content stays index-aligned with WASM. (text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start)] (commit-restyled-content shape-id content) (sync-wasm-text-editor-content!))) + + pending? + (sync-with-pending-caret-styles! shape-id start) + + :else (sync-wasm-text-editor-content!)))) (defn- reset-input-node @@ -315,8 +331,6 @@ (mf/use-fn (fn [^js event] (dom/prevent-default event) - ;; Pasted text keeps the surrounding style; drop any pending caret style. - (text-editor/clear-pending-caret-styles!) (when-let [fragment (some-> (.-clipboardData event) (clipboard->fragment (features/active-feature? @st/state "text-editor-wasm/v1-html-paste")))] diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index 963f49b688..4c806fd1c4 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -244,6 +244,17 @@ (.parseFromString html "text/html") (document->fragment))) +(defn without-overrides + "`fragment` as one plain run per paragraph, so it takes the style it is pasted + into. External sources keep only their text and paragraphs." + [fragment] + (mapv (fn [{:keys [children] :as paragraph}] + (assoc paragraph :children + (if (seq children) + [{:text (str/join (map :text children)) :attrs {}}] + []))) + fragment)) + (defn text->fragment "The paste fragment for plain text, one paragraph per line, or nil when empty." [text] diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index a8f65d6e4f..d2d757fbb0 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -163,3 +163,14 @@ (t/testing "empty text gives no fragment" (t/is (nil? (clipboard/text->fragment ""))))) + +(t/deftest without-overrides + (t/testing "external HTML keeps its text and paragraphs but no emphasis" + (t/is (= [(paragraph (plain-run "Hello bold and italic")) + (paragraph) + (paragraph (plain-run "Second"))] + (clipboard/without-overrides (html->fragment google-docs-html))))) + + (t/testing "empty paragraphs stay empty" + (t/is (= [(paragraph)] + (clipboard/without-overrides [(paragraph)]))))) From d47753dbb3fc91c857d1004e5052a487625ec7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 13:14:22 +0200 Subject: [PATCH 031/325] :wrench: Build Docker images on Renovate branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .github/workflows/build-renovate.yml | 252 +++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 .github/workflows/build-renovate.yml diff --git a/.github/workflows/build-renovate.yml b/.github/workflows/build-renovate.yml new file mode 100644 index 0000000000..bb7b9d9b40 --- /dev/null +++ b/.github/workflows/build-renovate.yml @@ -0,0 +1,252 @@ +name: Renovate Docker Image Check + +# Triggered by pushes to Renovate branches, not by pull_request: this is a +# public repository and these jobs run on the self-hosted runners, so a PR +# from a fork must never be able to start them. Only users with write +# access can push here. +on: + push: + branches: + - 'renovate-*' + +permissions: {} + +concurrency: + group: renovate-image-check-${{ github.ref_name }} + cancel-in-progress: true + +env: + BUNDLE_CACHE: /var/tmp/penpot-bundle-cache + +jobs: + # ── 1. Pick an existing develop bundle and detect what changed ──────── + prepare: + name: Prepare + runs-on: penpot-standar-runner + timeout-minutes: 10 + permissions: + contents: read + outputs: + short_sha: ${{ steps.bundle.outputs.short_sha }} + devenv: ${{ steps.changes.outputs.devenv }} + imagemagick: ${{ steps.changes.outputs.imagemagick }} + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Find the develop commit this branch is based on + id: base + run: | + echo "sha=$(git merge-base HEAD origin/develop)" >> "$GITHUB_OUTPUT" + + - name: Detect changed images + id: changes + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + run: | + changed() { + git diff --quiet "$BASE_SHA" HEAD -- "$1" && echo false || echo true + } + { + echo "devenv=$(changed docker/devenv)" + echo "imagemagick=$(changed docker/imagemagick)" + } >> "$GITHUB_OUTPUT" + + # Renovate only changes Dockerfiles and package manager versions, so + # the most recent develop bundle is enough to build the images. + - name: Find the latest develop bundle + id: bundle + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + BASE_SHA: ${{ steps.base.outputs.sha }} + run: | + for SHA in $(git rev-list --first-parent --max-count=50 "$BASE_SHA"); do + SHORT_SHA=$(git rev-parse --short=12 "$SHA") + if aws s3api head-object --bucket "$S3_BUCKET" \ + --key "penpot-sha-${SHORT_SHA}.zip" > /dev/null 2>&1; then + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "Using bundle \`penpot-sha-${SHORT_SHA}.zip\`" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + done + echo "::error::No bundle found for the last 50 develop commits" + exit 1 + + # ── 2. Production images, built from the PR Dockerfiles ─────────────── + images: + name: Build ${{ matrix.image }} + runs-on: penpot-standar-runner + timeout-minutes: 60 + needs: prepare + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 3 + matrix: + image: [backend, frontend, exporter, storybook, mcp] + + steps: + - name: Set common environment variables + env: + MATRIX_IMAGE: ${{ matrix.image }} + run: | + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}-${MATRIX_IMAGE}" >> "$GITHUB_ENV" + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to Docker Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to DockerHub Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Login to Docker Hardened Images registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Prepare Penpot bundle + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + IMAGE: ${{ matrix.image }} + run: | + ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + if [ ! -f "$ZIP" ]; then + mkdir -p "$BUNDLE_CACHE" + aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + mv "$ZIP.$$.tmp" "$ZIP" + fi + pushd docker/images + unzip -q "$ZIP" "penpot/${IMAGE}/*" + mv "penpot/${IMAGE}" "bundle-${IMAGE}" + popd + + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build Docker image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./docker/images/ + file: ./docker/images/Dockerfile.${{ matrix.image }} + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache + + # ── 3. Devenv, only when the PR touches it ──────────────────────────── + devenv: + name: Build devenv + runs-on: penpot-standar-runner + timeout-minutes: 90 + needs: prepare + if: needs.prepare.outputs.devenv == 'true' + permissions: + contents: read + + steps: + - name: Set common environment variables + run: | + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV" + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to DockerHub Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Login to Docker Hardened Images registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build devenv image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./docker/devenv/ + file: ./docker/devenv/Dockerfile + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=registry,ref=penpotapp/devenv:buildcache + + # ── 4. ImageMagick, only when the PR touches it ─────────────────────── + # amd64 only: compiling ImageMagick under QEMU takes too long for a check. + imagemagick: + name: Build imagemagick + runs-on: penpot-standar-runner + timeout-minutes: 60 + needs: prepare + if: needs.prepare.outputs.imagemagick == 'true' + permissions: + contents: read + + steps: + - name: Set common environment variables + run: | + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV" + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to Docker Hardened Images registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build imagemagick image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ./docker/imagemagick/ + file: ./docker/imagemagick/Dockerfile + platforms: linux/amd64 + push: false From bcd46310823564f65064e24191b50e52118493b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 10:55:58 +0000 Subject: [PATCH 032/325] :wrench: Add Renovate configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- renovate.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000000..bfe3f97ff5 --- /dev/null +++ b/renovate.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>penpot/renovate-config"], + "semanticCommits": "disabled", + "commitMessagePrefix": ":arrow_up:", + "commitMessageAction": "Update", + "pinDigest": { + "commitMessageAction": "Pin" + }, + "nvm": { + "enabled": false + }, + "customManagers": [ + { + "customType": "regex", + "description": "Keep .nvmrc in sync with the DHI Node image", + "managerFilePatterns": ["/^\\.nvmrc$/"], + "matchStrings": ["v(?\\d+\\.\\d+\\.\\d+)"], + "depNameTemplate": "dhi.io/node", + "datasourceTemplate": "docker", + "extractVersionTemplate": "^(?\\d+\\.\\d+\\.\\d+)-debian13$" + }, + { + "customType": "regex", + "description": "Keep the devenv Node in sync with the DHI Node image", + "managerFilePatterns": ["/^docker/devenv/Dockerfile$/"], + "matchStrings": ["NODE_VERSION=v(?\\d+\\.\\d+\\.\\d+)"], + "depNameTemplate": "dhi.io/node", + "datasourceTemplate": "docker", + "extractVersionTemplate": "^(?\\d+\\.\\d+\\.\\d+)-debian13$" + } + ], + "packageRules": [ + { + "description": "Node versions in plain text have no digest to pin", + "matchManagers": ["custom.regex"], + "matchDatasources": ["docker"], + "pinDigests": false + } + ] +} From e17c3c0c8fcfc0577d07bdcf99e1a9e10eb9818f Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:26:54 +0000 Subject: [PATCH 033/325] :arrow_up: Pin DHI base image digests - dhi.io/debian-base trixie-debian13-dev@61dc022 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@61dc022 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13@20079b5 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@61dc022 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@61dc022 (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.6-alpine3.24@9465275 (docker/images/Dockerfile.storybook) - dhi.io/nginx 1.31.6-debian13-dev@32e4340 (docker/images/Dockerfile.frontend) - dhi.io/node 24.21.0-debian13-dev@2e51e83 (docker/images/Dockerfile.media-processor) - dhi.io/node 24.21.0-debian13@f3575cf (docker/images/Dockerfile.mcp) - dhi.io/node 24.21.0-debian13-dev@2e51e83 (docker/images/Dockerfile.mcp) - dhi.io/node 24.21.0-debian13-dev@2e51e83 (docker/images/Dockerfile.exporter) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- docker/devenv/Dockerfile | 2 +- docker/imagemagick/Dockerfile | 4 ++-- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- docker/images/Dockerfile.storybook | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 3d841a45be..b198c875a1 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index 0ab72b37af..157897ca6e 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:20079b51710f0397da5e056bfc7156b6aafc7ff3aa4ef4cbcb0b0f1df99cd8c4 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index e2f1f15179..bf4a33b5cf 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 1153014be3..14eb31f211 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev +FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 7c6547779e..6e913f5919 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-debian13-dev +FROM dhi.io/nginx:1.31.6-debian13-dev@sha256:32e434084ff7e82629abc090c534fa0b02ebf1a7465f8029314bf9e95349c3a1 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 1394306bd1..f95a6869b5 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev AS build +FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.21.0-debian13 AS image +FROM dhi.io/node:24.21.0-debian13@sha256:f3575cf1e68ffe163703b691c465af833fd2d94cccc1e825d052c943018f2aa3 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index fbabbfb70f..4b16df1ca0 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.21.0-debian13-dev +FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 7428803510..13a887fb0a 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-alpine3.24 +FROM dhi.io/nginx:1.31.6-alpine3.24@sha256:94652754aa6bbf187859b24c0b547aa2c3d07e99d4462868f8d9f36bf2039d88 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From a61a4fceb15fde77ea09877b83c774e56314e300 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:08:49 +0000 Subject: [PATCH 034/325] :arrow_up: Update dependency pnpm to v12.8.1 - pnpm 12.8.1 (docker/images/Dockerfile.media-processor) - pnpm 12.8.1 (docker/images/Dockerfile.mcp) - pnpm 12.8.1 (docker/images/Dockerfile.exporter) - pnpm 12.8.1 (render-wasm/package.json) - pnpm 12.8.1 (plugins/package.json) - pnpm 12.8.1 (plugins/libs/plugins-styles/package.json) - pnpm 12.8.1 (plugins/libs/plugins-runtime/package.json) - pnpm 12.8.1 (plugins/libs/plugin-types/package.json) - pnpm 12.8.1 (plugins/apps/table-plugin/package.json) - pnpm 12.8.1 (plugins/apps/rename-layers-plugin/package.json) - pnpm 12.8.1 (plugins/apps/poc-tokens-plugin/package.json) - pnpm 12.8.1 (plugins/apps/poc-state-plugin/package.json) - pnpm 12.8.1 (plugins/apps/plugin-api-test-suite/package.json) - pnpm 12.8.1 (plugins/apps/lorem-ipsum-plugin/package.json) - pnpm 12.8.1 (plugins/apps/icons-plugin/package.json) - pnpm 12.8.1 (plugins/apps/example-styles/package.json) - pnpm 12.8.1 (plugins/apps/e2e/package.json) - pnpm 12.8.1 (plugins/apps/create-palette-plugin/package.json) - pnpm 12.8.1 (plugins/apps/contrast-plugin/package.json) - pnpm 12.8.1 (plugins/apps/composable-test-suite/package.json) - pnpm 12.8.1 (plugins/apps/colors-to-tokens-plugin/package.json) - pnpm 12.8.1 (package.json) - pnpm 12.8.1 (media-processor/package.json) - pnpm 12.8.1 (mcp/packages/server/package.json) - pnpm 12.8.1 (mcp/packages/plugin/package.json) - pnpm 12.8.1 (mcp/packages/common/package.json) - pnpm 12.8.1 (mcp/package.json) - pnpm 12.8.1 (library/package.json) - pnpm 12.8.1 (frontend/text-editor/package.json) - pnpm 12.8.1 (frontend/packages/ui/package.json) - pnpm 12.8.1 (frontend/packages/tokenscript/package.json) - pnpm 12.8.1 (frontend/packages/mousetrap/package.json) - pnpm 12.8.1 (frontend/packages/draft-js/package.json) - pnpm 12.8.1 (frontend/package.json) - pnpm 12.8.1 (exporter/package.json) - pnpm 12.8.1 (docs/package.json) - pnpm 12.8.1 (common/package.json) - pnpm 12.8.1 (backend/package.json) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- backend/package.json | 2 +- backend/pnpm-lock.yaml | 122 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 122 +++++++++--------- docker/images/Dockerfile.exporter | 6 +- docker/images/Dockerfile.mcp | 6 +- docker/images/Dockerfile.media-processor | 6 +- docs/package.json | 2 +- docs/pnpm-lock.yaml | 122 +++++++++--------- exporter/package.json | 2 +- exporter/pnpm-lock.yaml | 122 +++++++++--------- frontend/package.json | 2 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/tokenscript/package.json | 2 +- frontend/packages/ui/package.json | 2 +- frontend/pnpm-lock.yaml | 122 +++++++++--------- frontend/text-editor/package.json | 2 +- library/package.json | 2 +- library/pnpm-lock.yaml | 122 +++++++++--------- mcp/package.json | 2 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 2 +- mcp/pnpm-lock.yaml | 122 +++++++++--------- media-processor/package.json | 2 +- media-processor/pnpm-lock.yaml | 122 +++++++++--------- package.json | 2 +- .../apps/colors-to-tokens-plugin/package.json | 2 +- .../apps/composable-test-suite/package.json | 2 +- plugins/apps/contrast-plugin/package.json | 2 +- .../apps/create-palette-plugin/package.json | 2 +- plugins/apps/e2e/package.json | 2 +- plugins/apps/example-styles/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- .../apps/plugin-api-test-suite/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- .../apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- plugins/package.json | 2 +- plugins/pnpm-lock.yaml | 122 +++++++++--------- pnpm-lock.yaml | 122 +++++++++--------- render-wasm/package.json | 2 +- render-wasm/pnpm-lock.yaml | 122 +++++++++--------- 49 files changed, 715 insertions(+), 715 deletions(-) diff --git a/backend/package.json b/backend/package.json index 1e103493fe..06e280f11a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 18113d0170..61fb053a66 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index d921c77202..faef4c3980 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index a19e1083c1..df3f0eecaf 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 14eb31f211..8d41a57273 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -95,17 +95,17 @@ WORKDIR /opt/penpot/exporter # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); the generated ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.6.0 +ARG PNPM_VERSION=12.8.1 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='973af2b3eb9509416cf889a7336705062c936407d5fda07298814e7532f5dea1'; \ + PNPM_SHA256='75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3f4c66f668d0e84219679982d095b30da68325e1fac9284e7f5dc1584bd3d13e'; \ + PNPM_SHA256='db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f95a6869b5..d175718177 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.6.0 +ARG PNPM_VERSION=12.8.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \ @@ -20,11 +20,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='973af2b3eb9509416cf889a7336705062c936407d5fda07298814e7532f5dea1'; \ + PNPM_SHA256='75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3f4c66f668d0e84219679982d095b30da68325e1fac9284e7f5dc1584bd3d13e'; \ + PNPM_SHA256='db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index 4b16df1ca0..20831a6798 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -58,7 +58,7 @@ WORKDIR /opt/penpot/media-processor # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.6.0 +ARG PNPM_VERSION=12.8.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install gzip; \ @@ -67,11 +67,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='973af2b3eb9509416cf889a7336705062c936407d5fda07298814e7532f5dea1'; \ + PNPM_SHA256='75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3f4c66f668d0e84219679982d095b30da68325e1fac9284e7f5dc1584bd3d13e'; \ + PNPM_SHA256='db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index fb43d456ef..ef98f9bda3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^10.0.0", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 191f3ace31..20554cfc04 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index f596181f04..745c197685 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 36db736d8d..0b7c6140e3 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index 7618a35d4c..c2e408beac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 6618f61074..be8840e69d 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 64bbd16dad..28ca48e04a 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,7 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json index 565edc0855..8cead2bb50 100644 --- a/frontend/packages/tokenscript/package.json +++ b/frontend/packages/tokenscript/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index d5b9e71100..bf0a6272df 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5bc040bee7..a27cf5821c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index f47cdd851f..7b0b75daa9 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^5.0.2" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/library/package.json b/library/package.json index 70d3675175..a9d582dcdb 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0-RC1", "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index ec675f85ca..769fda13d3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index 98505ca3e5..980f26ce29 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.9" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index c8bdde4bd0..e91b9ea873 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index f4b5e9d68c..1a44059515 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index e9eaa9cc75..f2561193d2 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "dependencies": { "@modelcontextprotocol/node": "^2.1.0", "@modelcontextprotocol/server": "^2.1.0", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 00545675bb..1ebfccbe6d 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 155498f016..ee5114c7b9 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "dependencies": { "express": "^5.2.1", "multer": "^2.2.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 774b041d11..926fab2cd0 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 1289dce20b..5f6e9cbec0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index d7f66f0b8e..e26ce1c6ca 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 20aae69b6c..b50b96386f 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -27,5 +27,5 @@ "vite": "^8.2.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 341e530bc4..aa177c08d3 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 6c2ed36828..961a360e9f 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 5f837261ee..c3e0d53630 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 1f05146cdf..73f65ac488 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index a290d2afd0..2bf7416462 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 6e874c26f8..56989beb20 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 28f81cac97..38b786484c 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -20,5 +20,5 @@ "devDependencies": { "playwright": "^1.62.1" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 2c563bd33a..6cd5d0cf94 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 61654d0646..dd52a6e2d3 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 179d878ab5..2ac3b42cbc 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 6ab4bd4881..3fc0a470ef 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 2fac5e0feb..a836bdd829 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index c752df719c..891d97fbaf 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 26d4ee0d1d..6b84336dc3 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec" + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" } diff --git a/plugins/package.json b/plugins/package.json index 60354ab878..399d026962 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index 470b8c5bba..09b47524ea 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e2bdb7d80..58cb8b5a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index 795a79d0c7..0529bfc873 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec", + "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index b8a64120ea..79c219f770 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.6.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' From f38cb4dc07c188df617fb385ba6fa96e46b28937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 15:32:49 +0200 Subject: [PATCH 035/325] :fire: Remove unused package-lock.json from plugins-runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .../libs/plugins-runtime/package-lock.json | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 plugins/libs/plugins-runtime/package-lock.json diff --git a/plugins/libs/plugins-runtime/package-lock.json b/plugins/libs/plugins-runtime/package-lock.json deleted file mode 100644 index b484be67cd..0000000000 --- a/plugins/libs/plugins-runtime/package-lock.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@penpot/plugins-runtime", - "version": "0.12.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@penpot/plugins-runtime", - "version": "0.12.0", - "dependencies": { - "@penpot/plugin-types": "^0.12.0", - "ses": "^1.1.0", - "zod": "^3.22.4" - } - }, - "node_modules/@endo/env-options": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.6.tgz", - "integrity": "sha512-Uqy94PwLTco90Yfign43muvDtjsYTbL6Ck4W5sSWQUqJiFOL+YP8kiBOoGyQynDsGTY0MCQvMxEPfphaJKnzlQ==" - }, - "node_modules/@penpot/plugin-types": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@penpot/plugin-types/-/plugin-types-0.12.0.tgz", - "integrity": "sha512-rtSJ8memBAWxRBEx+S47NFDJN5VIvY0J7QzwTBer45Ls3BjLCaWXCnfFEcRn4SzY+Th01tmop0pwdvN5YDxkBw==" - }, - "node_modules/ses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/ses/-/ses-1.8.0.tgz", - "integrity": "sha512-pis9agyAy7s9lDApaYY9OSktkzc10gkBggSbhHXwTf3cWL9H8xA7oKiXIfT/uVNf8dzZN3o2TtPYjer9/axIhw==", - "dependencies": { - "@endo/env-options": "^1.1.6" - } - }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} From 472973533e0c889eb926f92db06f5d281fc71aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 14:34:59 +0000 Subject: [PATCH 036/325] :whale: Verify the Node.js tarball checksum in the devenv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- docker/devenv/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 619e826549..995284048a 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -76,12 +76,14 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ + NODE_SHA256='724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='468294b77633d29889c6e4a79cefb1277eba30cd62e7d6a754218cb85a1d730d'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ + NODE_SHA256='6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='b4f58449ac02d9d24023ac89e17e05e8331f8ddc12ca1f489cf231bc2bb2bce1'; \ ;; \ @@ -91,6 +93,7 @@ RUN set -eux; \ ;; \ esac; \ curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ + echo "${NODE_SHA256} */tmp/nodejs.tar.gz" | sha256sum -c -; \ mkdir -p /opt/node; \ cd /opt/node; \ tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ From 7a37cefe4cbd78c4292398fcec361316dfc55f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 6 Oct 2026 17:33:43 +0200 Subject: [PATCH 037/325] :bug: Pass team-id in binfile SVG import tests (#12139) AI-assisted-by: claude-opus-5-5 --- backend/test/backend_tests/binfile_svg_test.clj | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj index 500c497df1..41d8d212ad 100644 --- a/backend/test/backend_tests/binfile_svg_test.clj +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -255,6 +255,7 @@ _ (tamper-svg-entry! bundle evil media-id (utf8bytes evil-text) :content-type content-type) result (-> th/*system* (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input evil) (v3/import-files!))] From b6a3ab67efc917ce3b9199d87c774c22222259b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 18:00:24 +0200 Subject: [PATCH 038/325] :fire: Remove Dependabot configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .github/dependabot.yml | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 80e71fedab..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -version: 2 -updates: - # Actions are pinned to a commit SHA with a `# vX.Y.Z` comment; Dependabot - # bumps both. All updates are grouped into a single monthly PR. - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" - open-pull-requests-limit: 5 - labels: - - "dependencies" - - "team infra" - - "no issue required" - commit-message: - prefix: ":arrow_up:" - groups: - github-actions: - patterns: - - "*" From 8204e97a98aef628137e8eb29349d61ffab5b538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 15:31:27 +0000 Subject: [PATCH 039/325] :wrench: Manage GitHub Actions with Renovate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .github/workflows/build-renovate.yml | 6 +++++- renovate.json | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-renovate.yml b/.github/workflows/build-renovate.yml index bb7b9d9b40..6156aed086 100644 --- a/.github/workflows/build-renovate.yml +++ b/.github/workflows/build-renovate.yml @@ -28,6 +28,7 @@ jobs: contents: read outputs: short_sha: ${{ steps.bundle.outputs.short_sha }} + images: ${{ steps.changes.outputs.images }} devenv: ${{ steps.changes.outputs.devenv }} imagemagick: ${{ steps.changes.outputs.imagemagick }} @@ -49,9 +50,11 @@ jobs: BASE_SHA: ${{ steps.base.outputs.sha }} run: | changed() { - git diff --quiet "$BASE_SHA" HEAD -- "$1" && echo false || echo true + git diff --quiet "$BASE_SHA" HEAD -- "$@" && echo false || echo true } { + # Anything outside .github can affect the production images. + echo "images=$(changed . ':!.github')" echo "devenv=$(changed docker/devenv)" echo "imagemagick=$(changed docker/imagemagick)" } >> "$GITHUB_OUTPUT" @@ -85,6 +88,7 @@ jobs: runs-on: penpot-standar-runner timeout-minutes: 60 needs: prepare + if: needs.prepare.outputs.images == 'true' permissions: contents: read strategy: diff --git a/renovate.json b/renovate.json index bfe3f97ff5..1d7acb30de 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>penpot/renovate-config"], + "labels": ["dependencies", "team infra", "no issue required"], "semanticCommits": "disabled", "commitMessagePrefix": ":arrow_up:", "commitMessageAction": "Update", From 1afa8b1afa8e47211d84c0ab43b35b3e5a980891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 6 Oct 2026 16:02:06 +0000 Subject: [PATCH 040/325] :wrench: Skip triage for Renovate's dashboard and GitHub Actions PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .github/workflows/auto-label.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index e3c435e40d..84615efe4b 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -13,8 +13,14 @@ permissions: {} jobs: triage: - # Dependabot PRs are labelled by dependabot.yml and do not need triage. - if: github.actor != 'dependabot[bot]' + # The Renovate Dependency Dashboard and Renovate's GitHub Actions PRs do + # not need triage. Other Renovate PRs (base images, Node.js, pnpm) do. + if: >- + !(github.event_name == 'issues' + && github.event.issue.user.login == 'penpot-renovate[bot]') + && !(github.event_name == 'pull_request_target' + && github.event.pull_request.user.login == 'penpot-renovate[bot]' + && github.event.pull_request.head.ref == 'renovate-github-actions') runs-on: ubuntu-latest steps: - name: Generate GitHub App token From 2a6e92a7d9a5520365276f7b9a6ae59d478159ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 7 Oct 2026 10:45:17 +0200 Subject: [PATCH 041/325] :bug: Preserve path segments when joining backend URIs (#12152) --- backend/src/app/config.clj | 10 +++++++++- backend/test/backend_tests/config_test.clj | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 10a97b7bee..6c04c82861 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -432,6 +432,13 @@ (or (c/get config :file-clean-delay) (ct/duration {:days 2}))) +(defn- join-path-segments + [segments] + (let [path (->> segments (map str) (str/join "/"))] + (->> (str/split path #"/") + (remove str/blank?) + (str/join "/")))) + (defn join-uri "Join path segments onto a base URI, preserving a potential subpath (same semantics as the frontend config). The base is normalized with @@ -440,7 +447,8 @@ [base & segments] (assert (not (some #(str/starts-with? % "/") segments)) "URI segments must be relative (no leading slash)") - (str (apply u/join (u/ensure-path-slash base) segments))) + (str (u/join (u/ensure-path-slash base) + (join-path-segments segments)))) (defn get-public-uri "Canonical public URI builder: `join-uri` over the configured diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index 863be00a5e..3fb8a96e57 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -34,6 +34,15 @@ (t/is (= "https://nitrate.example.com/api/teams/123" (cf/join-uri "https://nitrate.example.com/" "api/teams/123"))))) +(t/deftest join-uri-joins-multiple-segments + (t/testing "path segments are appended instead of replacing previous segments" + (t/is (= "https://nitrate.example.com/admin-console/api/teams/team-id/users/profile-id" + (cf/join-uri "https://nitrate.example.com/admin-console" + "api/teams/" + "team-id" + "users/" + "profile-id"))))) + (t/deftest join-uri-rejects-leading-slash (t/testing "a leading slash would drop the subpath, so it fails fast" (t/is (thrown? AssertionError From a9bf53adbfc72dc8b3dfc630085e084ec0981ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 7 Oct 2026 11:10:02 +0200 Subject: [PATCH 042/325] Revert ":bug: Preserve path segments when joining backend URIs (#12152)" (#12156) This reverts commit 2a6e92a7d9a5520365276f7b9a6ae59d478159ee. --- backend/src/app/config.clj | 10 +--------- backend/test/backend_tests/config_test.clj | 9 --------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 6c04c82861..10a97b7bee 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -432,13 +432,6 @@ (or (c/get config :file-clean-delay) (ct/duration {:days 2}))) -(defn- join-path-segments - [segments] - (let [path (->> segments (map str) (str/join "/"))] - (->> (str/split path #"/") - (remove str/blank?) - (str/join "/")))) - (defn join-uri "Join path segments onto a base URI, preserving a potential subpath (same semantics as the frontend config). The base is normalized with @@ -447,8 +440,7 @@ [base & segments] (assert (not (some #(str/starts-with? % "/") segments)) "URI segments must be relative (no leading slash)") - (str (u/join (u/ensure-path-slash base) - (join-path-segments segments)))) + (str (apply u/join (u/ensure-path-slash base) segments))) (defn get-public-uri "Canonical public URI builder: `join-uri` over the configured diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index 3fb8a96e57..863be00a5e 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -34,15 +34,6 @@ (t/is (= "https://nitrate.example.com/api/teams/123" (cf/join-uri "https://nitrate.example.com/" "api/teams/123"))))) -(t/deftest join-uri-joins-multiple-segments - (t/testing "path segments are appended instead of replacing previous segments" - (t/is (= "https://nitrate.example.com/admin-console/api/teams/team-id/users/profile-id" - (cf/join-uri "https://nitrate.example.com/admin-console" - "api/teams/" - "team-id" - "users/" - "profile-id"))))) - (t/deftest join-uri-rejects-leading-slash (t/testing "a leading slash would drop the subpath, so it fails fast" (t/is (thrown? AssertionError From 051f01d559c772448adf7cb37b4f809bb12d3c34 Mon Sep 17 00:00:00 2001 From: Danny Shirely <69107616+danialshirali16@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:48:46 +0330 Subject: [PATCH 043/325] :bug: Keep per-side stroke widths out of inspect geometry (#12055) Remove per-side stroke widths from both Inspect geometry panels so Stroke is the only section that lists them. Keep dimensions, position, corner radii, and transforms in Size and position. Add rendered-component regressions for boards, rectangles, multiple selection, uniform strokes, and CSS declarations. Closes #12026 AI-assisted-by: GPT-6 Signed-off-by: Danial Shirali Signed-off-by: Eva Marco Co-authored-by: Eva Marco --- .../main/ui/inspect/attributes/geometry.cljs | 4 - .../ui/inspect/styles/panels/geometry.cljs | 5 - frontend/test/frontend_tests/runner.cljs | 2 + .../ui/inspect_geometry_test.cljs | 114 ++++++++++++++++++ 4 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/inspect_geometry_test.cljs diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs index 4f99f5dba8..477a6f01b0 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs @@ -22,10 +22,6 @@ :left :top :border-radius - :border-block-start-width ; top - :border-inline-end-width ; right - :border-block-end-width ; bottom - :border-inline-start-width ; left :transform]) (mf/defc geometry-block diff --git a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs index 53d95fb00e..b5dca89400 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs @@ -22,11 +22,6 @@ :border-start-end-radius :border-end-start-radius :border-end-end-radius - ;; side strokes - :border-block-start-width - :border-inline-end-width - :border-block-end-width - :border-inline-start-width :transform]) (def ^:private shape-prop->border-radius-prop diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e45dd24bd4..501973b6e8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -110,6 +110,7 @@ [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.history-test] + [frontend-tests.ui.inspect-geometry-test] [frontend-tests.ui.inspect-stroke-tokens-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measurements-test] @@ -251,6 +252,7 @@ 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.history-test + 'frontend-tests.ui.inspect-geometry-test 'frontend-tests.ui.inspect-stroke-tokens-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measurements-test diff --git a/frontend/test/frontend_tests/ui/inspect_geometry_test.cljs b/frontend/test/frontend_tests/ui/inspect_geometry_test.cljs new file mode 100644 index 0000000000..3b9c5879d2 --- /dev/null +++ b/frontend/test/frontend_tests/ui/inspect_geometry_test.cljs @@ -0,0 +1,114 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.inspect-geometry-test + (:require + ["react-dom/server" :as rds] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.ui.hooks :as hooks] + [app.main.ui.inspect.attributes.geometry :as geometry] + [app.main.ui.inspect.attributes.stroke :as stroke] + [app.main.ui.inspect.styles.panels.geometry :as styles-geometry] + [app.main.ui.inspect.styles.panels.stroke :as styles-stroke] + [app.util.code-gen.style-css :as css] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [frontend-tests.helpers.mock :as mock] + [rumext.v2 :as mf])) + +(def ^:private per-side-stroke + {:stroke-style :solid + :stroke-alignment :inner + :stroke-color "#000000" + :stroke-opacity 1 + :stroke-width 2 + :stroke-width-top 2 + :stroke-width-right 4 + :stroke-width-bottom 8 + :stroke-width-left 16}) + +(defn- shape + [type stroke] + {:id (uuid/next) + :type type + :parent-id uuid/zero + :frame-id uuid/zero + :selrect (grc/make-rect 0 0 120 80) + :points [(gpt/point 0 0) (gpt/point 120 0) + (gpt/point 120 80) (gpt/point 0 80)] + :transform (gmt/matrix) + :r1 5 :r2 5 :r3 5 :r4 5 + :strokes [stroke]}) + +(defn- render-panel + [panel shapes] + (rds/renderToStaticMarkup + (mf/element panel + #js {:shapes shapes + :objects (into {uuid/zero {:id uuid/zero + :type :frame + :selrect (grc/make-rect 0 0 120 80)}} + (map (juxt :id identity)) shapes) + :resolvedTokens {} + :colorSpace "hex" + :onGeometryShorthand identity + :onStrokeShorthand identity}))) + +(t/deftest geometry-panels-exclude-stroke-widths + (doseq [type [:frame :rect] + panel [geometry/geometry-panel* styles-geometry/geometry-panel*]] + (t/testing (str "geometry for " (name type)) + (let [markup (render-panel panel [(shape type per-side-stroke)])] + (t/is (not (re-find #"Border (block|inline).*? width" markup))) + (t/is (str/includes? markup "120px")) + (t/is (str/includes? markup "80px")) + (t/is (str/includes? markup "5px")))))) + +(t/deftest geometry-panels-exclude-stroke-widths-in-multiple-selection + (let [shapes [(shape :frame per-side-stroke) (shape :rect per-side-stroke)]] + (doseq [panel [geometry/geometry-panel* styles-geometry/geometry-panel*]] + (t/is (not (re-find #"Border (block|inline).*? width" + (render-panel panel shapes))))))) + +(t/deftest stroke-panels-keep-per-side-widths + (with-redefs [cf/flags (conj cf/flags :inspect-styles) + hooks/use-portal-container (mock/stub (fn [_] nil))] + (doseq [type [:frame :rect] + panel [stroke/stroke-panel* styles-stroke/stroke-panel*]] + (let [markup (render-panel panel [(shape type per-side-stroke)])] + (doseq [label ["Border block start width" "Border inline end width" + "Border block end width" "Border inline start width"]] + (t/is (str/includes? markup label))) + (doseq [value ["2px" "4px" "8px" "16px"]] + (t/is (str/includes? markup value))))))) + +(t/deftest code-view-keeps-each-per-side-width-once + (let [shape (shape :frame per-side-stroke) + code (css/get-shape-properties-css {(:id shape) shape} shape css/shape-css-properties)] + (doseq [declaration ["border-block-start-width: 2px;" + "border-inline-end-width: 4px;" + "border-block-end-width: 8px;" + "border-inline-start-width: 16px;"]] + (t/is (= 1 (count (re-seq (re-pattern declaration) code))))))) + +(t/deftest uniform-strokes-keep-width-in-the-stroke-panel + (let [uniform-stroke (-> per-side-stroke + (dissoc :stroke-width-top :stroke-width-right + :stroke-width-bottom :stroke-width-left) + (assoc :stroke-width 3)) + shapes [(shape :rect uniform-stroke)]] + (doseq [panel [geometry/geometry-panel* styles-geometry/geometry-panel*]] + (t/is (not (str/includes? (render-panel panel shapes) "Border width")))) + (with-redefs [cf/flags (conj cf/flags :inspect-styles) + hooks/use-portal-container (mock/stub (fn [_] nil))] + (doseq [panel [stroke/stroke-panel* styles-stroke/stroke-panel*]] + (let [markup (render-panel panel shapes)] + (t/is (str/includes? markup "Border width")) + (t/is (str/includes? markup "3px"))))))) From accea1abdecbc1c038714f6d77b7fe0d3850309d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 7 Oct 2026 11:30:40 +0200 Subject: [PATCH 044/325] :zap: Enable wasm SIMD for Skia render-wasm builds (#12145) Link with -msimd128 and point SKIA_BINARIES_URL at the pdf+simd prebuilt archive. --- render-wasm/_build_env | 6 +++++- render-wasm/lint | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 2b653b185f..dc03bebd89 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -20,7 +20,7 @@ fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} # 256 MB of initial heap to perform less # initial calls to memory grow. @@ -39,8 +39,12 @@ export EM_MEMORY_GROWTH_GEOMETRIC_STEP="0.8" # Default: dlmalloc export EM_MALLOC="dlmalloc" +# -msimd128: allow wasm SIMD in the linked module. Skia prebuilts must be +# built with the same flag (penpot/skia-binaries, WASM_SIMD=1) for Skia/skcms +# kernels to actually contain v128 ops; engines do not autovec scalar wasm. export EMCC_CFLAGS="--no-entry \ --js-library src/js/wapi.js \ + -msimd128 \ -sMALLOC=$EM_MALLOC \ -sINVOKE_RUN=0 \ -sALLOW_TABLE_GROWTH=0 \ diff --git a/render-wasm/lint b/render-wasm/lint index 4ed7d6826d..0489e1040d 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -11,7 +11,7 @@ fi . ./_build_env frontend export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} ALLOWED_RULES="-D static_mut_refs" From 9abcfebf60c332d0940644b5ba7607cb1a799837 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 7 Oct 2026 11:52:48 +0200 Subject: [PATCH 045/325] :sparkles: Run binfile import and export as durable jobs (#12056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Run binfile import and export as durable jobs Import and export of `.penpot` files become durable jobs: two commands create them and answer at once with a job id the caller follows, while a worker of the `binfile` queue does the work. Each job keeps its own ledger (owner, params frozen at creation, result, progress history and an expiry), and the legacy commands and their SSE keep working. Why: both operations ran inside the request. A large import held it open for minutes, progress only existed while the caller kept the stream open, and a client that went away left work nobody recorded, retried or could cancel. The unified `job` substrate already dispatched, leased, retried and logged events, but it had no user-facing ledger. How: - The substrate gains the pieces a user-facing job needs: optional job-def metadata (family, resource-role), an expiry on submit, the owner in the runner context, and job resources in their own bucket, written with the profile of the job and never deduplicated. - One generic command per family: the caller names the job and sends its business params, the registry checks the family, the job-def decodes and validates, and permission and quota are checked first. - Progress taps of the core become milestones of the job: a shared stage vocabulary with self-contained counters, throttled heartbeats, and an interrupt that stops the run inside its own transaction on cancel. - Cancel reaches pending and running jobs from both modals, and closing a modal mid-run cancels the job it was following. - The export re-checks the read permission at run time and answers with the package descriptor; the import re-checks the edition permission, runs the core in a transaction so a cancellation rolls it back, and releases the consumed package and its temporary copy. AI-assisted-by: deepseek-v4.1-flash * :recycle: Handle externally-cancelled binfile jobs end to end The `cancelled` outcome of a job nobody followed no longer hangs its screens: the data layers emit an explicit terminal message for it. - The export emits `{:cancelled true}` per file and the modal paints a neutral cancelled row instead of success; a message without an artifact never reaches the download branch. - The import closes every entry with a dedicated cancelled message through the existing error rendering, so the wizard completes. - Both flows are pinned by data-layer tests, including the red state they replace. Also in this change: the progress-contract comment of the management API, early business-params validation in create-import-job (before assembling the upload), a `release-resource` helper without fabricated job ids, a note on the heartbeat write cost, and a memory cleanup. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Pre-translate the cancelled import message The cancelled entry of the import wizard carried its message as a key for the UI to translate, which needed a rehash marker comment. Like its neighboring errors, it now translates eagerly in the data layer: the literal call keeps rehash clean with no marker, and the tests assert through the same call instead of the raw key. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Move cancelled strings under generic labels keys The dashboard-specific cancelled key never shipped: before use it moves under `labels.*` next to the generic strings both flows already use, with Spanish translations following the established wording. AI-assisted-by: muse-spark-1.3-contributor * :sparkles: Add minor improvements * :recycle: Rename export job command to create-export-binfile-job Export now has one command per job type, the shape the :export-assets command needs: create-export-binfile-job freezes exactly the :export-binfile job, so :name no longer travels in the body and only :params does. Import keeps the generic envelope. submit-job reads the queue from the job-def metadata ::jobs/queue-name (falling back to :binfile), read by the command and never by the substrate, so a job-def can route its work to another queue without a change in the command. The rename reaches the frontend and the tests: the binfile export is asked for under its own name, and the exporter service keeps create-export-job. AI-assisted-by: deepseek-v4.1-flash * :sparkles: Polish the import and export status rows The status row of a file already carries what the file is doing, so the generic "Uploading file…"/"Downloading file…" lines are gone: the import wizard drops its own (the export one went with the jobs refactor), along with the now-unused style. In the export, every file is queued up front, even the ones whose job is created only after the previous one is over, and a file stays queued until its first milestone: a worker picking the job up no longer ends the queue. The queued text is a step smaller than the milestone. AI-assisted-by: deepseek-v4.1-flash * :bug: Show the cancelled export and fix the import error The cancelled export was unreachable: mark-file-cancelled sets :loading false and the label lived inside the loading branch, so a cancelled file only painted a neutral notification. The terminal state now carries the label, and the export flags lose the `?` suffix (predicates keep it; a data prop is not a question). The import wizard rendered (:error entry) through tr, but every producer writes user-facing text (a hint or a translated label), never a key: it renders the text as-is now, and the dynamic-key ignores and the stale comment are gone. The total-text-limit comment no longer references the 200MiB it replaced; the value is 500MiB in develop already. AI-assisted-by: deepseek-v4.1-flash * :recycle: Reflect the real job params in the creation commands The creation commands declared their params as an open empty map, so the RPC contract and the generated docs said nothing about them. Each command now imports the schema from the job namespace (`export-binfile/schema:params`, `import-binfile/schema:create-params`), and the RPC validates the business params before the command runs. The job-def still owns the params and re-validates the assembled ones at run time. The commands no longer decode the params again: the RPC did it. Import joins export as one command per job type: `create-import-job` becomes `create-import-binfile-job`, its body no longer carries `:name` and the job-def is resolved by name like export's. Its creation schema keeps the version optional, because the command reads it from the package header when the caller does not say it, and fills the manifest metadata kept for audit. A version out of range is now refused by the schema (`:params-validation`) instead of the command's own `:unsupported-version` check, which is gone. AI-assisted-by: deepseek-v4.1-flash * :bug: Stop the test system from running job runners The test system is main/system-config plus main/worker-config with a few components dissoc'ed. The runners of default, webhook and cron were listed by hand; the binfile runner added with the durable binfile jobs was not, so a real penpot/job-runner/binfile/0 thread ran during the whole suite. That runner races the tests that drive the loop by hand (run-batch plus run-worker-loop): it takes the job the test just dispatched and completes it, and the test then reads a state it did not produce. That is the reported flake of binfile-jobs-integration-test, whose middle assertion waits for a runner of another queue to not take the job. It only reproduced in CI: in the devenv PENPOT_TENANT makes the tenant baked into main/worker-config differ from the one the test builds, so the two used different queue keys locally. Drop every [ :app.worker/runner] by key shape instead of listing the known queues, so a new queue cannot bring the race back. AI-assisted-by: deepseek-v4.1-flash * :sparkles: Upload the package one chunk at a time and as a percentage The upload milestone counts chunks, not units of work, so the row wrote "Uploading the package 3/9" over a file the user had uploaded once: it read as if nine packages were on their way. Write the stage as a percentage of the file instead ("Uploading file… (33%)"), and make the English text of the stage say file, not package. The chunks also go one at a time now (concat-all instead of merge-all with two in flight), so the percentage moves in a straight line instead of jumping. upload-blob-chunked is the generic uploader, so media and font uploads become sequential too. AI-assisted-by: deepseek-v4.1-flash * :globe_with_meridians: Complete the Spanish catalogue es.po was 25 keys short of en.po: the 15 that the durable binfile jobs introduced (14 jobs.progress.stage.* and labels.queued) plus 10 gaps that predate them (errors.connection-error, errors.save-retrying, the find shortcuts, workspace.header.retrying, workspace.tokens.stroke-width and the mcp session labels). sync -l es then sorts the entries, syncs the #: comments from en and drops the 7 dashboard.import.progress.* keys en no longer has. The msgid sets of en and es are now identical (0 missing, 0 extra). AI-assisted-by: deepseek-v4.1-flash * :recycle: Move the job status keys into the jobs namespace The three status words the durable import and export rows render were filed under labels.*, next to the generic labels bucket, while their progress stages live under jobs.progress.stage.*: the strings of one feature sat in two places. Rename them so the whole job vocabulary is one namespace: labels.queued -> jobs.queued labels.export-cancelled -> jobs.export-cancelled labels.import-cancelled -> jobs.import-cancelled No other locale has these keys yet, so the rename costs no translation. The convention (a key names the feature that renders it; labels.* holds the generic reusable words) is written down in the frontend/translations memory. AI-assisted-by: deepseek-v4.1-flash * :fire: Remove the translation keys nothing references Six keys of en.po (and of es.po, which now mirrors it) have no (tr ...) call site left in frontend/src or common/src, and no literal reference either: labels.uploading-file a duplicate of jobs.progress.stage.upload since the upload stage says "Uploading file…" labels.downloading-file dead since the durable jobs replaced the old import and export progress rows inspect.attributes.stroke.alignment.center inspect.attributes.stroke.alignment.inner inspect.attributes.stroke.alignment.outer workspace.toolbar.mcp-connect-here The first two are leftovers of the durable binfile jobs, the other four predate them. Both catalogues keep an identical msgid set afterwards. AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/backend/core.md | 15 +- .serena/memories/backend/storage.md | 4 +- .serena/memories/backend/subtleties.md | 4 +- .serena/memories/backend/testing.md | 19 +- .serena/memories/clojure/design-rules.md | 1 + .../frontend/dashboard-viewer-subtleties.md | 1 + .../frontend/routing-app-shell-subtleties.md | 3 +- .serena/memories/frontend/translations.md | 11 +- .serena/memories/prod-infra/core.md | 4 +- backend/resources/climit.edn | 6 + backend/src/app/binfile/common.clj | 52 +- backend/src/app/binfile/jobs.clj | 171 +++++ backend/src/app/binfile/v3.clj | 247 +++--- backend/src/app/config.clj | 16 + backend/src/app/http/assets.clj | 30 +- backend/src/app/http/sse.clj | 15 +- backend/src/app/jobs.clj | 268 +++++-- backend/src/app/jobs/storage.clj | 152 ++++ backend/src/app/main.clj | 26 + backend/src/app/rpc.clj | 1 + backend/src/app/rpc/commands/binfile.clj | 39 +- backend/src/app/rpc/commands/jobs.clj | 355 +++++++++ backend/src/app/rpc/management/jobs.clj | 31 +- backend/src/app/rpc/quotes.clj | 56 ++ backend/src/app/storage.clj | 6 +- backend/src/app/storage/schema.clj | 3 +- backend/src/app/tasks/export_binfile.clj | 94 +++ backend/src/app/tasks/import_binfile.clj | 126 +++ backend/src/app/util/events.clj | 62 +- backend/src/app/worker/runner.clj | 30 +- .../test/backend_tests/binfile_jobs_test.clj | 403 ++++++++++ backend/test/backend_tests/config_test.clj | 20 +- backend/test/backend_tests/helpers.clj | 22 +- .../test/backend_tests/http_assets_test.clj | 42 + .../test/backend_tests/jobs_metrics_test.clj | 21 +- .../test/backend_tests/jobs_storage_test.clj | 205 +++++ backend/test/backend_tests/jobs_test.clj | 362 ++++++--- .../backend_tests/rpc/commands/jobs_test.clj | 717 ++++++++++++++++++ .../rpc_management_jobs_test.clj | 33 +- .../test/backend_tests/rpc_quotes_test.clj | 78 ++ backend/test/backend_tests/storage_test.clj | 40 + .../tasks/binfile_jobs_integration_test.clj | 268 +++++++ .../tasks/export_binfile_test.clj | 193 +++++ .../tasks/import_binfile_test.clj | 254 +++++++ .../test/backend_tests/util_events_test.clj | 77 ++ .../test/backend_tests/worker_runner_test.clj | 5 +- backend/test/backend_tests/worker_test.clj | 22 + frontend/src/app/main/data/exports/files.cljs | 84 +- frontend/src/app/main/data/imports.cljs | 294 +++++++ frontend/src/app/main/data/jobs.cljs | 94 +++ frontend/src/app/main/data/uploads.cljs | 33 +- frontend/src/app/main/repo.cljs | 1 + .../src/app/main/ui/dashboard/import.cljs | 183 ++--- .../src/app/main/ui/dashboard/import.scss | 9 +- frontend/src/app/main/ui/exports/files.cljs | 172 ++++- frontend/src/app/main/ui/jobs/progress.cljs | 69 ++ frontend/src/app/worker.cljs | 1 - frontend/src/app/worker/import.cljs | 253 ------ .../data/exports_files_test.cljs | 286 +++++++ .../frontend_tests/data/imports_test.cljs | 533 +++++++++++++ .../test/frontend_tests/data/jobs_test.cljs | 176 +++++ .../frontend_tests/data/uploads_test.cljs | 40 + frontend/test/frontend_tests/runner.cljs | 8 + .../frontend_tests/ui/jobs_progress_test.cljs | 44 ++ .../test/frontend_tests/util_zip_test.cljs | 31 +- frontend/translations/en.po | 128 ++-- frontend/translations/es.po | 170 +++-- 67 files changed, 6267 insertions(+), 952 deletions(-) create mode 100644 backend/src/app/binfile/jobs.clj create mode 100644 backend/src/app/jobs/storage.clj create mode 100644 backend/src/app/rpc/commands/jobs.clj create mode 100644 backend/src/app/tasks/export_binfile.clj create mode 100644 backend/src/app/tasks/import_binfile.clj create mode 100644 backend/test/backend_tests/binfile_jobs_test.clj create mode 100644 backend/test/backend_tests/jobs_storage_test.clj create mode 100644 backend/test/backend_tests/rpc/commands/jobs_test.clj create mode 100644 backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj create mode 100644 backend/test/backend_tests/tasks/export_binfile_test.clj create mode 100644 backend/test/backend_tests/tasks/import_binfile_test.clj create mode 100644 backend/test/backend_tests/util_events_test.clj create mode 100644 frontend/src/app/main/data/imports.cljs create mode 100644 frontend/src/app/main/data/jobs.cljs create mode 100644 frontend/src/app/main/ui/jobs/progress.cljs delete mode 100644 frontend/src/app/worker/import.cljs create mode 100644 frontend/test/frontend_tests/data/exports_files_test.cljs create mode 100644 frontend/test/frontend_tests/data/imports_test.cljs create mode 100644 frontend/test/frontend_tests/data/jobs_test.cljs create mode 100644 frontend/test/frontend_tests/ui/jobs_progress_test.cljs diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 287cd56dd2..1c4dc27119 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -57,10 +57,19 @@ For deeper details on transaction semantics, advisory locks, Transit vs JSON hel Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init). The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref). - `::jobs/handler` is `(fn [context params] ...)`. It is NOT given the runner cfg: a job-def closes over its own dependencies, and handing it the runner cfg would let a handler reach the pool or any other component. A wrapper passes the context to its implementation only when the implementation takes it; most do not, and their implementation stays `[cfg params]`. -- The context is built by `jobs/make-context` and has exactly four keys: `id`, `name`, `label`, `resource-id`, behind a closed schema. `queue` is absent on purpose (a routing detail of the dispatcher, not a property of the job) and so are `retry-num`/`max-retries`/`attempt` (retry policy belongs to the runner, and an attempt number cannot be durable because the `noop` strategy re-runs without incrementing the counter). +- The context is built by `jobs/make-context` and has exactly five keys: `id`, `name`, `label`, `resource-id`, `profile-id`, behind a closed schema. `profile-id` is the owner of a user-facing job and nil for an internal one, and it is there so a handler can revalidate permissions and check who owns its resource without a second query. `queue` is absent on purpose (a routing detail of the dispatcher, not a property of the job) and so are `retry-num`/`max-retries`/`attempt` (retry policy belongs to the runner, and an attempt number cannot be durable because the `noop` strategy re-runs without incrementing the counter). - `jobs/invoke` is the in-process escape hatch: it delivers a nil context without a row, validates and delivers `::jobs/context` when given, and keeps `::jobs/job-id` independent. `::jobs/context` and `::jobs/job-id` are cfg options, not params. -- Business params live in the `params` column (plain JSON, decoded with the job-def decoder). `submit` takes an optional `::jobs/profile-id` and `::jobs/resource-id`, both validated as UUIDs and stored as columns; nothing is inferred from `params`. No caller passes a profile-id yet. -- `heartbeat` uses named options `[cfg & {:keys [job-id progress] :as options}]` and returns the number of durable writes. The job id falls back to `::jobs/job-id` on the cfg and then to the runner-bound `*job-id*`. A progress report needs `current`, allows optional `total` and `stage` (max 250 chars) and takes no other key. +- Business params live in the `params` column (plain JSON, decoded with the job-def decoder). `submit` takes optional `::jobs/profile-id`, `::jobs/resource-id` (both validated as UUIDs) and `::jobs/expires-at` (an instant: the retention the jobs GC sweeps by), all stored as columns; nothing is inferred from `params`. The binfile creation commands are the callers that pass a profile-id and a resource. +- A job-def may carry optional metadata keys read from the registry by its consumers, never by the substrate: `::jobs/family` (e.g. `:export`) groups the jobs of a family for quotes, `::jobs/resource-role` (`:input` or `:output`) says whether the job's `resource_id` is what it consumes or what it produces, and `::jobs/queue-name` names the queue the creation command routes the job to (`submit-job` falls back to `:binfile` when the def does not set it). +- Job resources (`app.jobs.storage`) are the objects a job owns through `job.resource_id`: they live in the `job-resource` bucket, are written with the profile of the job as owner (only that profile can read them back from `/assets/by-id`) and are never deduplicated. They carry no expiry of their own, because `expired-at` is stored as `deleted_at` and an object with it is unreadable from birth: the retention of the artifact is the retention of its job row. The writer marks the object as touched right away, so an artifact that no row references (a crash between the upload and the completion) is reclaimed by `storage-gc-touched`, and a row that does reference it freezes it until the jobs GC deletes the row and touches the object. +- The binfile jobs (`:export-binfile` and `:import-binfile`) run the core of `bf.v1`/`bf.v3` through `app.binfile.jobs`, which turns the `:progress` taps of the core into `progress` events of the job with `jobs/heartbeat` (the `modified_at` touch at most once per second, progress events at up to ~200ms). Each event is a self-contained milestone: a `:stage` keyword of one vocabulary shared by the two formats (the same work has a different name in v1 and v3) and an optional `:counters` map, with the counter of the activity named after its own stage and the outer scopes in course as context, which the tap carries itself (the reader puts `::outer-counters` in every tap inside them — today only `:files`, extendable to `:teams`/`:projects` for multi-scope exports — so the adapter remembers nothing between taps and a tap that happens outside any scope has no context); the ids and names the taps carry never reach the event. Every tap is reported, so a tap with no units to count still keeps the job alive, and a milestone the throttle drops loses nothing because the next one carries the same state. The adapter checks that the job is still active before the run, and the beat raises an `:interrupt` right there on the run thread when the job is no longer active, so the work stops at its next event point inside the transaction of its handler, which rolls back. A run whose last tap predates the interruption is discarded by the check once it returns. The adapter passes the job id from the handler context. Their job-def components carry pool, storage, metrics and msgbus, because a job with a `profile_id` cannot store an event without a msgbus on its cfg. Both readers answer with the same shape (`:file-ids` and, in version 3, the resolution of the libraries), because the version 1 reader returns a bare set of file ids. +- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it, because the job-def has no result schema of its own. +- The domain cfg of a binfile request lives in `app.binfile.common` (`export-cfg`, `import-cfg`), so the legacy RPC and the jobs freeze the same ids, resolve the same team features and apply the same anti-zip-bomb limits. `resolve-export-type` is the translation the legacy RPC needs for its deprecated boolean flags; the creation command asks the caller for the type instead of guessing a default. +- Two retention knobs, easy to confuse: `:jobs-retention` sweeps terminal internal rows (the ones without a profile), while `:jobs-user-ttl` is the default expiry a user-facing job gets when it is created; the jobs GC deletes the row once `expires_at` passes and touches its resource. The heavy work of a user job runs on its own `:binfile` queue (`:worker-binfile-parallelism`, default 1) so it does not block the default runner. +- The quote of a family of jobs (`::export-jobs-per-profile`, `::import-jobs-per-profile`) counts the jobs of the profile that are still on their way (`new`/`scheduled`/`running`/`retry`) whose name the registry gives for that family, so a new job of the family is covered without touching the quote. +- A user-facing job is created through `app.rpc.commands.jobs`. One command per job type: `create-export-binfile-job` and `create-import-binfile-job` name the job they create and their body carries only `:params`, whose schema the command imports from the job namespace (`::sm/params`), so the RPC validates the business params up front, before the command runs; the job-def is still the place that owns them and validates them again at run time. The commands check the permission and the quote of the family before the expensive part, submit with `max-retries 0`, an expiry from `:jobs-user-ttl` and the queue the job-def routes to through `::jobs/queue-name` (`:binfile` when the def says nothing; read by the command, never by the substrate), and answer with a summary of the row. The import command has its own creation schema (`import-binfile/schema:create-params`, version optional) because the request is not the stored params: the command fills the version (from the caller or the package header) and the manifest metadata kept for audit. It assembles the upload, reads the manifest of a version 3 package for the audit (never its content) and stores the package in `job-resource` as the resource of the job, releasing it when the job never comes to own it. +- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is the plain JSON the row holds, so a uuid comes back as the text it is on disk (a result has no schema of its own like the params do). The event log still has no read surface: a client that follows a job takes its progress from the websocket. +- `heartbeat` uses named options `[cfg & {:keys [job-id progress] :as options}]` and returns the number of durable writes, so 0 means there was nothing to write: no job context, or the throttle did not allow it. A write that finds the job no longer active (terminal or gone) raises an `:interrupt` (`code :job-interrupted`, carrying the job id and the status it found) through the shared `jobs/check-active` instead of counting zero, so a task in course stops at its next beat; the interrupt is internal and never reaches HTTP (the management progress report answers `:skip` for it). The job id falls back to `::jobs/job-id` on the cfg and then to the runner-bound `*job-id*`. The `modified_at` touch is throttled to ~1s and the progress event to ~200ms, so the check fires at most once per second with no extra query (the read of the row happens only when a write already found nothing). A progress report is a milestone (`schema:progress`): a required `:stage` keyword plus an optional `:counters` map of `scope -> {:current, :total?}`, and no other key. Counters are non-negative, a `total` is positive and never lower than its `current`, and stage and counter keys are names of at most 64 characters. A milestone without counters is valid (a point of the run with no units to count), the stage vocabulary belongs to the worker and not to the substrate, and a client must degrade for a stage it does not know. Read a stored payload back with `jobs/decode-progress`: the database holds the keywords as strings. The 1s beat (down from 60s, so a cancelled heavy job is seen as gone at its next beat) costs one `modified_at` touch per beating job per second: cheap while the only heavy beater runs alone on its queue, revisit if concurrent beating jobs ever grow. - `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. For worker dispatch, cron, retry semantics (`ex/raise :type ::wrk/retry` with `:delay`/`:strategy`), deduplication, job events, and queue internals: `mem:backend/subtleties`. diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index baeba165a2..58cb134930 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -77,6 +77,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` - Objects can therefore share content across users and files within one bucket. - Deleted objects are not reused. - `tempfile` objects never use deduplication, even when the caller requests it. +- `job-resource` objects never use deduplication either, whatever the caller asks: the object belongs to one profile, and a shared blob would serve the artifact of one user to the job of another. `put-object!` excludes the bucket from the dedupable set, so the invariant does not depend on call sites. - Use `sto/wrap-with-hash` when the caller already calculated the content hash. ## Bucket Rules @@ -94,7 +95,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` | `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. | | `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. | | `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. | -| `job-resource` | Storage objects owned by job rows (`job.resource_id`). | Yes | Authentication required | jobs-GC touch → reference scan. | +| `job-resource` | Storage objects owned by job rows (`job.resource_id`), written by `app.jobs.storage`. | No | Authentication required, owner-scoped | jobs-GC touch → reference scan. | - The valid bucket set is `app.storage.schema/metadata-buckets` (derived from `bucket-requirements`, where a new bucket and its required metadata keys are one entry); `app.storage/valid-buckets` aliases it. - `file-media-object` is the default bucket for old rows without bucket metadata. @@ -108,6 +109,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` - `app.http.assets` decides direct object authentication from the bucket. - Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`. - Other valid buckets require a session or access-token profile ID. +- `tempfile` and `job-resource` are owner-scoped: the request must come from the profile stored in the object metadata, and a mismatch answers 404 (counted as `unauthorized`). A missing owner is tolerated only in `tempfile`, where legacy objects predate the metadata; `job-resource` always carries one, so an object without it is never served. - File-media routes also require file read permission. - Non-public direct responses set `content-disposition: attachment`. - FS responses use `x-accel-redirect` for the configured asset path. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 41f781070b..3e1b73a482 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -9,6 +9,7 @@ - Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists. Client params are stripped of qualified keys (`d/without-qualified`) before merging with the server auth context, so request bodies cannot override `::profile-id`, `::auth-type`, or `::token-perms`. - Params schemas are open by default, so undeclared client keys reach the handler unless the map is `:closed true`. Creation commands (`create-file`, `create-project`, `create-team`, `create-team-with-invitations`, `upload-file-media-object`, `create-file-media-object-from-url`, `assemble-file-media-object`) use closed schemas: a client-provided `:id` fails with `:params-validation`. Their internal creation functions still accept an optional explicit `:id` for imports, duplicates and deterministic test fixtures. - Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. +- The upload schema (`media.v/schema:upload`) takes a `:path` from the caller, and a Transit body can tag any string as a `path`, so a command that reads such an upload reads a file the client named. `create-import-binfile-job` avoids that boundary by accepting only a chunked upload (`:upload-id`); the commands that still take a plain file (the legacy `import-binfile`, the media and font uploads) trust it, and the guard belongs where uploads are accepted, not in each command. ## DB helpers @@ -25,7 +26,7 @@ - `job` carries `tenant` (the instance the job belongs to) and a bare `queue`. Several instances share one database and the tenant is what keeps an instance from claiming another environment's jobs, so every query that acts on one instance's work filters on `tenant`: dispatch claim, orphan sweep, lost-job reschedule, cron no-overlap count, submit dedupe. The `job__dispatcher__idx` leads with `tenant` (equality) then `(priority DESC, scheduled_at)`; `queue` does not lead because the dispatcher claims every queue of its tenant. The `:` prefix survives only in the Redis key, composed once by `app.worker/queue-key`. - Submit dedupe only removes not-yet-due `new` jobs of the same tenant with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. - The dispatcher selects `new`/`retry` jobs with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes the JSON payload `[id scheduled-at]` to the `penpot.worker.queue::` Redis list. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. -- Lost `scheduled` jobs are rescheduled after 5 minutes; `running` jobs untouched longer than `:jobs-lease` (default 30 min) are marked `aborted` with `app.jobs/orphan-error` (`type :internal`, `code :orphan`). `aborted` is a system-side terminal state like `cancelled` (never retried, swept by retention like other terminals, reported with an error log); long jobs must `heartbeat` per chunk so a healthy backlog is never mistaken for a dead process. The `:progress` report is optional and only feeds the durable log. +- Lost `scheduled` jobs are rescheduled after 5 minutes; `running` jobs untouched longer than `:jobs-lease` (default 30 min) are marked `aborted` with `app.jobs/orphan-error` (`type :internal`, `code :orphan`). `aborted` is a system-side terminal state like `cancelled` (never retried, swept by retention like other terminals, reported with an error log); long jobs must `heartbeat` per chunk so a healthy backlog is never mistaken for a dead process. The `:progress` report is optional and only feeds the durable log. A beat whose write finds the job no longer active raises an `:interrupt` (`code :job-interrupted`) instead of counting zero: it is the internal stop-the-work signal, not an error, so neither the listener nor the runner logs it as one. - An orphan gets its `end` event (`outcome "aborted"`) from the sweep itself: one bulk `INSERT ... SELECT` in the same transaction as the bulk `UPDATE`, never one insert per orphan. No msgbus notification goes out for it; orphans alert through the error log and the orphaned counter. - `job_event` is append-only and deleted by cascade with the job. `progress` is a row there, not a column: no Redis and no mutable column to keep in sync. - `job` is deletion-protected at the SQL level (`deletion_protection__tgr`): a stray `DELETE FROM job` raises instead of dropping the row and its `job_event` cascade without touching the referenced `storage_object`. The only intended deleters, the jobs GC and the submit dedupe, disable the guard with `SET LOCAL rules.deletion_protection TO off` inside their transaction; tests use `th/db-force-delete`. @@ -36,6 +37,7 @@ ## Config and HTTP/session middleware - `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. +- Duration config values are parsed as ISO-8601 with the `PT` prefix, so a day is written in hours (`:jobs-retention` and `:jobs-user-ttl` default to a week, and an operator writes `168h`): `7d` does not parse, and the value fails validation at boot instead of being silently ignored. - `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. - The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. - Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. diff --git a/.serena/memories/backend/testing.md b/.serena/memories/backend/testing.md index cacfbadfca..75c049e475 100644 --- a/.serena/memories/backend/testing.md +++ b/.serena/memories/backend/testing.md @@ -12,4 +12,21 @@ JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`. - Multiple focus symbols are supported by REPEATING the flag (kaocha accumulates them, last-wins does not apply): `clojure -M:dev:test --focus backend-tests.ns-one --focus backend-tests.ns-two` — preferred over a shell loop of isolated runs. - Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas. - If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. -- RPC test helpers `command!`/`management-command!` split the data map: qualified keys become server params, unqualified keys become request body params. To inject request-level context (headers, `:app.http/auth-key-id`, ip), pass a map under `:app.http/request` metadata; non-map `IRequest` stubs fall back to a dummy request. \ No newline at end of file +- RPC test helpers `command!`/`management-command!` split the data map: qualified keys become server params, unqualified keys become request body params. To inject request-level context (headers, `:app.http/auth-key-id`, ip), pass a map under `:app.http/request` metadata; non-map `IRequest` stubs fall back to a dummy request. +- The test system is `main/system-config` + `main/worker-config` with the + components that must not run in the background dropped: HTTP, the srepl, + cron, the dispatcher and **every** `[ :app.worker/runner]` + (`helpers/remove-job-runners`). A live runner of any queue races the tests + that drive the loop by hand (`wdisp/run-batch` + `@#'wrkr/run-worker-loop`): + it takes the job the test just dispatched and completes it behind the test's + back, so the test's own assertions read a state it did not produce. Do not + re-add a runner by editing that list by hand; the removal matches by key + shape on purpose. +- `system-config` and `worker-config` are `def`s: their `(cf/get ...)` calls + are evaluated when the namespace loads, against the real environment, while + test code calling `cf/get` at run time sees the `penpot-test`-prefixed + config. The two can disagree — in the devenv `:tenant` is `devenv-ws0` for + the defs and `default` inside a test — so a component built from + `worker-config` can talk to a different Redis key prefix than a cfg the test + builds itself. The symptom is a component that silently does nothing + locally and races in CI (which sets no `PENPOT_TENANT`). \ No newline at end of file diff --git a/.serena/memories/clojure/design-rules.md b/.serena/memories/clojure/design-rules.md index edf273e152..cac863cc16 100644 --- a/.serena/memories/clojure/design-rules.md +++ b/.serena/memories/clojure/design-rules.md @@ -6,6 +6,7 @@ How to shape a function in this codebase. Each rule came from a review round; do - **Optional-by-design data is guarded where the optionality is born.** Example: `ConnectorStatistics` is nil when the server option is off, so the guard is `when-let [cs (some-> server ...)]` at the entry point and the consumer assumes `cs`. - **A static set of operations is written statically.** With a fixed, small set (four metrics) write the four calls. Do not build a collection and iterate it (`doseq` over a literal vector): it allocates, adds indirection and hides each operation. Use a collection when the set is dynamic (config, registry, input). - **Default to no `!`; existing `!` is legacy being removed gradually.** Do not introduce new `!` names except the allowed cases documented in `mem:clojure/idioms` (add and justify the special case there first). Allowed: mutating public fns in `app.db` (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, ...) and fns whose contract is to work over transients (stdlib `conj!`/`assoc!`/`dissoc!`/`persistent!` and custom helpers over them). Existing `!` elsewhere (e.g. `emit!`, `send!`, `submit!`, `validate-file!`) stays until its refactor; keep a family consistent in the meantime. Reporting helpers (log, sample-and-publish) never take `!`: the mutation happens in the `!` API they call. +- **`?` marks a predicate, not a data prop.** A var or function that answers a question takes `?` (`export-error?`, `(defn editable? …)`); a map key that names a property does not (`:export-error`, never `:export-error?`), even when its value is a boolean. Existing `?` keys are legacy to remove gradually; do not add a new one. - **Do not shape production code for tests.** No return values added just to assert them; assert on the observable effect. When preconditions move, move the tests with them. Reference implementation: the http metrics samplers in `app.http` (`sample-worker-metrics` / `sample-connector-metrics` are guard-free; `sample-http-metrics` guards at the boundary). diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index f4cfd45cc9..097d3648fb 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -5,6 +5,7 @@ - Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id. - Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly. - Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. +- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The legacy SSE import stays in the backend for other clients. - File creation/duplication strips file `:data` before putting file summaries into dashboard state. ## Viewer diff --git a/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md index ea3e98243d..5c99aae4da 100644 --- a/.serena/memories/frontend/routing-app-shell-subtleties.md +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -19,4 +19,5 @@ For general store mechanics such as `emit!`, `last-events`, persistence, and undo, read `mem:frontend/workspace-state-persistence-subtleties`. - Websocket initialization uses `cf/public-uri` joined with `ws/notifications`, converting `http/https` to `ws/wss`, and includes the current `session-id` as query param. -- Reinitializing or finalizing websocket stops the previous receive stream. Incoming websocket payloads become Potok data events under `app.main.data.websocket/message`. \ No newline at end of file +- Reinitializing or finalizing websocket stops the previous receive stream. Incoming websocket payloads become Potok data events under `app.main.data.websocket/message`. +- A job the client created is followed with `app.main.data.jobs/watch-job`: it emits the `:job-event` messages of that job (filtered by `:job-id`) and one final outcome read from the row with `get-job`, which is read when the stream starts and again on every `opened` of the websocket, so a job that ended before the client started following it, or while the socket was down, still answers. The stream ends after that outcome, and a read that fails reaches the caller: the repo retries the transient transport errors of a GET, and anything else is a real failure the flow has to show. The `:stage` of a progress event is a stable key of the vocabulary of the worker, never a display string: `app.main.ui.jobs.progress` resolves it with a `case` of literal `tr` calls, falls back to a generic label for a stage it does not know, and the counter of the activity is named after its own stage (the counter of `:files` is the outer context). \ No newline at end of file diff --git a/.serena/memories/frontend/translations.md b/.serena/memories/frontend/translations.md index 7053af9d4b..806ac5d990 100644 --- a/.serena/memories/frontend/translations.md +++ b/.serena/memories/frontend/translations.md @@ -7,10 +7,11 @@ high-coverage support reference, never the base. ## Workflow - Canonicalize with `node ./scripts/translations.js sync -l ` from `frontend/`: sorts entries, syncs `#:` comments from `en`, deletes keys missing in `en`. +- `sync` never ADDS a key missing in the locale: it only syncs comments (for keys that already exist) and drops keys absent from `en`. A key added to `en` alone stays missing in `es` and its text silently falls back to English. Every new key is born in `en` AND `es` in the same change; compare the `msgid` sets to check (`comm -23 <(grep '^msgid ' en.po | sort -u) <(grep '^msgid ' es.po | sort -u)` must be empty). - `sync` copies ALL comment flags from `en`, including `#, fuzzy`. Translated entries must NOT stay fuzzy: strip the flag after translating (mirror `es.po`, which keeps fuzzy only on genuinely untranslated entries). Fuzzy entries are excluded from `msgfmt --statistics` translated counts. - Canonical files carry NO `#~` obsolete blocks (`en`/`es` have zero). Drop them; `sync` does not resurrect them. -- Definition of done: `msgfmt --check .po` exit 0 and `msgfmt --statistics` shows 0 fuzzy, 0 untranslated. -- `rehash` scans `frontend/src` AND `common/src` for `(tr "key"` occurrences (any line counts, including `;;` comments) and refreshes `#:` refs in `en`, marking unreferenced keys `#, unused`. +- Definition of done: `msgfmt --check .po` exit 0, `msgfmt --statistics` shows 0 fuzzy, 0 untranslated, and the locale's `msgid` set is identical to `en.po`'s (no key missing, none extra). +- `rehash` scans `frontend/src` AND `common/src` for `(tr "key"` occurrences (any line counts, including `;;` comments) and refreshes `#:` refs in `en`, marking unreferenced keys `#, unused`. The refs in `en` are broadly stale, so a repo-wide `rehash` rewrites hundreds of lines (measured: ~487 on `en` alone): it is a maintenance action on its own, never a step to run inside a change that only adds or edits a key. - Dynamic keys are invisible to `rehash`: eliminate them instead of declaring them. Preference order: literal `(tr "key")` args only; never put a branch inside `tr`, hoist it out (`(if cond (tr "a") (tr "b"))`); @@ -28,6 +29,12 @@ high-coverage support reference, never the base. ## Entry rules +- Placement: a key names the feature that renders it (`dashboard.import.*`, + `files-export-modal.*`, `jobs.*`); `labels.*` is the bucket for the short + generic words reused across screens (Accept, Cancel, Download…), never the + home of a feature's own vocabulary. Keep a feature's strings together: the + durable jobs write their progress stages as `jobs.progress.stage.*` and + their row statuses as `jobs.*`, not half in `labels.*`. - Keys resolved dynamically from server-provided data carry a `#, backend` flag (set it in `en`, `sync` copies it to the locales): do not remove or rename these entries, and keep their `%s` placeholders (the backend only diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index d4999d86b8..7872efa128 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -12,9 +12,9 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose ## Task queue and worker model -Async jobs are enqueued via `jobs/submit` (`app.jobs`), which inserts a row into the shared Postgres `job` table tagged with `queue = ":"`. Submission is **fire-and-forget** — RPC handlers never poll and never wait. The completion signal is the `job` row's `status` / `completed_at` columns, which nothing in `rpc/` reads today. A job submitted with `::jobs/profile-id` also publishes a `:job-event` message on that profile's msgbus topic after each transaction commits, but no caller passes a profile-id yet, so nothing reaches msgbus in production. Soft-delete RPCs return immediately after marking the top-level row, leaving the cascade and reaping to workers. +Async jobs are enqueued via `jobs/submit` (`app.jobs`), which inserts a row into the shared Postgres `job` table tagged with `queue = ":"`. Submission is **fire-and-forget** — RPC handlers never poll and never wait. The completion signal is the `job` row's `status` / `completed_at` columns, which the creation commands read back to answer with the state of the job they just created. A job submitted with `::jobs/profile-id` also publishes a `:job-event` message on that profile's msgbus topic after each transaction commits, and the binfile commands (`create-export-binfile-job`, `create-import-binfile-job`) are the callers that pass that owner, so the progress of their jobs does reach the msgbus. Soft-delete RPCs return immediately after marking the top-level row, leaving the cascade and reaping to workers. -Workers run on backends with `enable-backend-worker` in `PENPOT_FLAGS`. Each worker-enabled backend has a `dispatcher` (polls `job` with `FOR UPDATE SKIP LOCKED`, marks status='scheduled', RPUSHes the JSON `[id scheduled-at]` payloads into **its own** Redis list) and one or more `runner`s per queue (BLPOP from that same local list, execute, update the Postgres row). The Redis hand-off list is purely intra-backend — cross-backend coordination happens at the Postgres row level. +Workers run on backends with `enable-backend-worker` in `PENPOT_FLAGS`. Each worker-enabled backend has a `dispatcher` (polls `job` with `FOR UPDATE SKIP LOCKED`, marks status='scheduled', RPUSHes the JSON `[id scheduled-at]` payloads into **its own** Redis list) and one or more `runner`s per queue (BLPOP from that same local list, execute, update the Postgres row). The Redis hand-off list is purely intra-backend — cross-backend coordination happens at the Postgres row level. The queues are `default`, `webhooks`, `cron` and `binfile`: the last one carries the heavy work of a user job (`:worker-binfile-parallelism`, default 1), so a long export or import does not block the default runner. ## Cross-backend safety diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index c109b2afdb..1f59a0d3d3 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -53,6 +53,12 @@ :import-binfile/by-profile {:permits 1 :queue 2} + :create-import-binfile-job/global + {:permits 4} + + :create-import-binfile-job/by-profile + {:permits 1 :queue 2} + :profile-plugin-ops/global {:permits 4} diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 2275a2cc6d..42085f7647 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -73,11 +73,11 @@ ;; Maximum total decompressed size allowed for all JSON/text zip entries ;; combined within a single import job: 500MiB. Bounds the case where many ;; entries, each individually under default-max-text-entry-size, still sum -;; to an unreasonable total. Set well above the 200MiB it replaces because -;; legitimate giant exports exist: hundreds of thousands of KB-sized -;; entries (pages, shapes) that never trip the per-entry cap but sum past -;; 400MiB. The per-entry cap still bounds single-entry DEFLATE -;; amplification, so this only moves the cumulative ceiling. +;; to an unreasonable total. Set this high because legitimate giant +;; exports exist: hundreds of thousands of KB-sized entries (pages, +;; shapes) that never trip the per-entry cap but sum past 400MiB. The +;; per-entry cap still bounds single-entry DEFLATE amplification, so this +;; only moves the cumulative ceiling. (def ^:const default-max-text-total-size (* 1024 1024 500)) @@ -1029,3 +1029,45 @@ {:bytes bytes :size (alength ^bytes bytes) :hash (sto/calculate-hash (java.io.ByteArrayInputStream. bytes))}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; REQUEST CFG +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn resolve-export-type + "The canonical export type of a request on the legacy RPC: an explicit + `type` wins, and the deprecated boolean flags are translated. The + creation command of a job does not need it, because it asks the caller + for the type instead of guessing it." + [{:keys [type include-libraries embed-assets]}] + (cond + (some? type) type + (true? include-libraries) :include-libraries + (true? embed-assets) :merge-libraries + :else :detach-libraries)) + +(defn export-cfg + "The domain cfg of an export: the frozen set of files and how their + libraries are handled." + [cfg {:keys [ids export-type]}] + (-> cfg + (assoc ::ids ids) + (assoc ::export-type export-type))) + +(defn import-cfg + "The domain cfg of an import: the destination team/profile/project, the + name, the input and the anti-zip-bomb limits. The caller resolves the + team, because what allows it to import there differs between the RPC and + a job; what both must share is fixed here." + [cfg {:keys [profile-id project-id team name input]}] + (-> cfg + (assoc ::features (cfeat/get-team-enabled-features cf/flags team)) + (assoc ::project-id project-id) + (assoc ::profile-id profile-id) + (assoc ::team-id (:id team)) + (assoc ::name name) + (assoc ::input input) + (assoc ::import-max-binary-entry-size (cf/get :binfile-import-max-binary-entry-size)) + (assoc ::import-max-text-entry-size (cf/get :binfile-import-max-text-entry-size)) + (assoc ::import-max-text-total-size (cf/get :binfile-import-max-text-total-size)) + (assoc ::import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))) diff --git a/backend/src/app/binfile/jobs.clj b/backend/src/app/binfile/jobs.clj new file mode 100644 index 0000000000..c947ae858a --- /dev/null +++ b/backend/src/app/binfile/jobs.clj @@ -0,0 +1,171 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.binfile.jobs + "The binfile work a job runs. + + A job has no SSE channel, so the `:progress` points that `bf.v1` and + `bf.v3` emit become `progress` events of the job through + `jobs/heartbeat`, which throttles them and drops the ones that do not + fit. The adapter turns a tap into a self-contained milestone: a `:stage` + of a single vocabulary (the two formats name the same work differently) + and the counters the tap carries, with the outer scopes in course as + context. The reader puts the outer counters in every tap inside them, + so the adapter holds no state between taps. The ids the taps carry are + not part of the job event contract. + + The adapter checks that the job is still active before it starts, and + the beat checks it on every write: a job that was cancelled, or that + already reached a terminal state, raises an `:interrupt` right there on + the run thread, so the work stops at its next event point and the + transaction of its handler rolls back with it. A run whose last tap + happened before the interruption is discarded once it returns, which is + what makes that transaction roll back too." + (:require + [app.binfile.common :as bfc] + [app.binfile.v1 :as bf.v1] + [app.binfile.v3 :as bf.v3] + [app.common.exceptions :as ex] + [app.common.time :as ct] + [app.db :as db] + [app.jobs :as jobs] + [app.util.events :as events])) + +(defn check-active + "Raise an `:interrupt` when the job is no longer active. + + The substrate owns both the check and the error; this is the adapter's + entry point to it, used by the two handlers before their run and by the + tests." + [cfg context] + (jobs/check-active cfg (:id context))) + +(def ^:private stage-of + "The stage a tap section becomes: one vocabulary for the two formats, so + a client resolves each stage once. The counter of the activity is named + after its own stage, which is what lets a client render + `counters[stage]` without a table of equivalences." + {:file :files + :page :pages + :media :media + :thumbnail :thumbnails + :thumbnails :thumbnails + :component :components + :color :colors + :typography :typographies + :tokens-lib :tokens + :tokens-status :tokens + :storage-object :storage-objects + :storage-objects :storage-objects + :relations :relations + :manifest :manifest + :v1/metadata :metadata + :v1/files :files + :v1/rels :relations + :v1/sobjects :storage-objects}) + +(defn- stage + "The stage of a tap section; a section without a name of its own keeps + it, so a new tap is never dropped from the stream." + [section] + (get stage-of section section)) + +(defn- counter + "The counter a tap reports, when it reports one: a tap that only marks a + point of the run has no units to count." + [{:keys [current total]}] + (when (some? current) + (cond-> {:current current} + (some? total) + (assoc :total total)))) + +(defn- milestone + "The milestone a tap becomes: what the job is doing now and the counters + the tap carries, with the outer scopes in course as context. + + The tap of a scope carries its outer counters, so the milestone is + self-contained and the adapter remembers nothing between taps: a client + that misses one (the substrate throttles the events) still renders the + truth from the next." + [{:keys [section outer-counters] :as tap}] + (let [stage (stage section) + own (counter tap)] + (cond-> {:stage stage} + (or (some? outer-counters) (some? own)) + (assoc :counters (cond-> (or outer-counters {}) + (some? own) (assoc stage own)))))) + +(defn- with-progress + "Run `f` with the progress taps of the core turned into milestones of the + job. + + Every tap is reported: the substrate throttles the durable events, and + a milestone that does not fit loses no information because the next one + carries the same state. + + The taps run on this same thread: the sink calls the heartbeat inline, + so an `:interrupt` it raises aborts the core right there, inside the + transaction of its handler, which rolls back." + [cfg context f] + (let [job-id (:id context) + sink (fn [[type data]] + (when (= :progress type) + (jobs/heartbeat cfg + :job-id job-id + :progress (milestone data))))] + (binding [events/*sink* sink] + (let [result (f)] + (check-active cfg context) + result)))) + +(defn export-files + "Export a set of files into `output` as part of a job, reporting + progress as job events." + [cfg context {:keys [ids export-type output]}] + (check-active cfg context) + (with-progress cfg context + #(bf.v3/export-files! (bfc/export-cfg cfg {:ids ids + :export-type export-type}) + output))) + +(defn- touch-project + "Mark the destination project as modified. + + An import adds files, and the triggers of the schema only touch the + project on an update, so a project that just received files keeps its + old date and stays where it was in the list." + [cfg project-id] + (db/update! cfg :project + {:modified-at (ct/now)} + {:id project-id} + {::db/return-keys false})) + +(defn import-files + "Import the package at `input` as part of a job, reporting progress as + job events. The package was read from the resource of the job, so the + caller resolves the destination team and the adapter only fixes what + the legacy RPC and a job must share. + + Both formats answer with the same shape: the version 1 reader returns + the set of files it created, and the version 3 one returns a map that + already carries them together with the resolution of the libraries." + [cfg context {:keys [profile-id project-id team name input version]}] + (check-active cfg context) + (let [cfg (bfc/import-cfg cfg {:profile-id profile-id + :project-id project-id + :team team + :name name + :input input})] + (with-progress cfg context + #(let [result (case (int version) + 1 {:file-ids (vec (bf.v1/import-files! cfg))} + 3 (bf.v3/import-files! cfg) + (ex/raise :type :validation + :code :unsupported-version + :hint "unsupported binfile version" + :version version))] + (touch-project cfg project-id) + result)))) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 1d411336f7..338a4a8a6a 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -231,6 +231,32 @@ (def validate-file-thumbnail (sm/check-fn schema:file-thumbnail)) +;; --- PROGRESS + +(defn- tap-progress + "Emit a progress tap of the core, with the counters of the outer scopes + in course when the cfg has any. + + The outer counters live in `::outer-counters` of the cfg while a scope + is being processed (today only `:files`, tomorrow `:teams` and + `:projects` too), so every tap inside carries them as `:outer-counters`: + the stages inside do not repeat them, and whoever reads the taps (the + jobs adapter) builds its report from one tap alone instead of + remembering the last position it saw. A tap that happens outside any + scope carries no outer counters, which is the truth about it." + [cfg data] + (events/tap :progress + (cond-> data + (some? (::outer-counters cfg)) + (assoc :outer-counters (::outer-counters cfg))))) + +(defn- with-outer-counter + "Return cfg with the counter of `scope` set to `position`, keeping the + counters of the other outer scopes already in course, so entering a + nested scope never wipes the ones above it." + [cfg scope position] + (assoc-in cfg [::outer-counters scope] position)) + ;; --- EXPORT IMPL (defn- write-entry! @@ -264,8 +290,10 @@ (defn- export-storage-objects [{:keys [::output] :as cfg}] - (let [storage (sto/resolve cfg)] - (doseq [id (-> bfc/*state* deref :storage-objects not-empty)] + (let [storage (sto/resolve cfg) + ids (-> bfc/*state* deref :storage-objects not-empty) + total (count ids)] + (doseq [[index id] (d/enumerate ids)] (let [sobject (sto/get-object storage id) smeta (meta sobject) ext (cmedia/mtype->extension (:content-type smeta)) @@ -277,7 +305,8 @@ (write-entry! output path params) - (events/tap :progress {:section :storage-object :id id}) + (tap-progress cfg {:section :storage-object :id id + :current (inc index) :total total}) (with-open [input (sto/get-object-data storage sobject)] (.putNextEntry ^ZipOutputStream output (ZipEntry. (str "objects/" id ext))) @@ -305,7 +334,7 @@ thumbnails (bfc/get-file-object-thumbnails cfg file-id)] - (events/tap :progress {:section :file :id file-id :name (:name file)}) + (tap-progress cfg {:section :file :id file-id :name (:name file)}) (vswap! bfc/*state* update :files assoc file-id {:id file-id @@ -339,7 +368,8 @@ (write-entry! output path page) - (events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id}) + (tap-progress cfg {:section :page :id page-id :name (:name page) :file-id file-id + :current (inc index) :total (count pages)}) (doseq [[shape-id shape] objects] (let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json") @@ -350,30 +380,33 @@ (vswap! bfc/*state* bfc/collect-storage-objects media) (vswap! bfc/*state* bfc/collect-storage-objects thumbnails) - (doseq [{:keys [id] :as media} media] - (let [path (str "files/" file-id "/media/" id ".json") - media (encode-media media)] + (doseq [[index {:keys [id] :as item}] (d/enumerate media)] + (let [path (str "files/" file-id "/media/" id ".json") + encoded (encode-media item)] - (events/tap :progress {:section :media :id id :file-id file-id}) - (write-entry! output path media))) + (tap-progress cfg {:section :media :id id :file-id file-id + :current (inc index) :total (count media)}) + (write-entry! output path encoded))) - (doseq [thumbnail thumbnails] + (doseq [[index thumbnail] (d/enumerate thumbnails)] (let [data (cth/parse-object-id (:object-id thumbnail)) path (str "files/" file-id "/thumbnails/" (:tag data) "/" (:page-id data) "/" (:frame-id data) ".json") data (-> data (assoc :media-id (:media-id thumbnail)) (encode-file-thumbnail))] - (events/tap :progress {:section :thumbnails :id (:object-id thumbnail) :file-id file-id}) + (tap-progress cfg {:section :thumbnails :id (:object-id thumbnail) :file-id file-id + :current (inc index) :total (count thumbnails)}) (write-entry! output path data))) - (doseq [[id component] components] + (doseq [[index [id component]] (d/enumerate components)] (let [path (str "files/" file-id "/components/" id ".json") component (encode-component component)] - (events/tap :progress {:section :component :id id :file-id file-id}) + (tap-progress cfg {:section :component :id id :file-id file-id + :current (inc index) :total (count components)}) (write-entry! output path component))) - (doseq [[id color] colors] + (doseq [[index [id color]] (d/enumerate colors)] (let [path (str "files/" file-id "/colors/" id ".json") color (-> (encode-color color) (dissoc :file-id)) @@ -381,20 +414,22 @@ (and (contains? color :path) (str/empty? (:path color))) (dissoc :path))] - (events/tap :progress {:section :color :id id :file-id file-id}) + (tap-progress cfg {:section :color :id id :file-id file-id + :current (inc index) :total (count colors)}) (write-entry! output path color))) - (doseq [[id object] typographies] + (doseq [[index [id object]] (d/enumerate typographies)] (let [path (str "files/" file-id "/typographies/" id ".json") typography (encode-typography object)] - (events/tap :progress {:section :typography :id id :file-id file-id}) + (tap-progress cfg {:section :typography :id id :file-id file-id + :current (inc index) :total (count typographies)}) (write-entry! output path typography))) (when (and tokens-lib (not (ctob/empty-lib? tokens-lib))) (let [path (str "files/" file-id "/tokens.json") encoded-tokens (encode-tokens-lib tokens-lib)] - (events/tap :progress {:section :tokens-lib :file-id file-id}) + (tap-progress cfg {:section :tokens-lib :file-id file-id}) (write-entry! output path encoded-tokens))) (when tokens-status @@ -410,7 +445,7 @@ encoded-status (-> (encode-tokens-status tokens-status) (cfo/ids->names effective-tokens-lib))] - (events/tap :progress {:section :tokens-status :file-id file-id}) + (tap-progress cfg {:section :tokens-status :file-id file-id}) (write-entry! output path encoded-status))))) (defn- export-files @@ -449,7 +484,8 @@ (doseq [[index file-id] (d/enumerate ids)] (-> cfg (assoc ::file-id file-id) - (assoc ::file-seqn index) + (with-outer-counter :files {:current (inc index) + :total (count ids)}) (export-file))) ;; Write manifest file @@ -742,17 +778,19 @@ (defn- read-file-colors [{:keys [::bfc/input ::entries-index] :as cfg} file-id] - (->> (get-in entries-index [:colors (str file-id)]) - (reduce (fn [result {:keys [id entry]}] - (let [object (->> (read-entry cfg input entry) - (decode-color) - (validate-color))] - (events/tap :progress {:section :color :id id :file-id file-id}) - (if (= id (:id object)) - (assoc result id object) - result))) - {}) - (not-empty))) + (let [entries (get-in entries-index [:colors (str file-id)])] + (->> (d/enumerate entries) + (reduce (fn [result [index {:keys [id entry]}]] + (let [object (->> (read-entry cfg input entry) + (decode-color) + (validate-color))] + (tap-progress cfg {:section :color :id id :file-id file-id + :current (inc index) :total (count entries)}) + (if (= id (:id object)) + (assoc result id object) + result))) + {}) + (not-empty)))) (defn- read-file-components [{:keys [::bfc/input ::entries-index] :as cfg} file-id] @@ -771,15 +809,18 @@ (reduce-kv (fn [objects id shape] (assoc objects id (bfl/clean-shape-pre-decode shape))) objects - objects))))] + objects)))) + entries + (get-in entries-index [:components (str file-id)])] - (->> (get-in entries-index [:components (str file-id)]) - (reduce (fn [result {:keys [id entry]}] + (->> (d/enumerate entries) + (reduce (fn [result [index {:keys [id entry]}]] (let [object (->> (read-entry cfg input entry) (clean-component-pre-decode) (decode-component) (clean-component-post-decode))] - (events/tap :progress {:section :component :id id :file-id file-id}) + (tap-progress cfg {:section :component :id id :file-id file-id + :current (inc index) :total (count entries)}) (if (= id (:id object)) (assoc result id object) result))) @@ -788,22 +829,24 @@ (defn- read-file-typographies [{:keys [::bfc/input ::entries-index] :as cfg} file-id] - (->> (get-in entries-index [:typographies (str file-id)]) - (reduce (fn [result {:keys [id entry]}] - (let [object (->> (read-entry cfg input entry) - (decode-typography) - (validate-typography))] - (events/tap :progress {:section :typography :id id :file-id file-id}) - (if (= id (:id object)) - (assoc result id object) - result))) - {}) - (not-empty))) + (let [entries (get-in entries-index [:typographies (str file-id)])] + (->> (d/enumerate entries) + (reduce (fn [result [index {:keys [id entry]}]] + (let [object (->> (read-entry cfg input entry) + (decode-typography) + (validate-typography))] + (tap-progress cfg {:section :typography :id id :file-id file-id + :current (inc index) :total (count entries)}) + (if (= id (:id object)) + (assoc result id object) + result))) + {}) + (not-empty)))) (defn- read-file-tokens-lib [{:keys [::bfc/input ::entries-index] :as cfg} file-id] (when-let [{:keys [entry]} (first (get-in entries-index [:tokens (str file-id)]))] - (events/tap :progress {:section :tokens-lib :file-id file-id}) + (tap-progress cfg {:section :tokens-lib :file-id file-id}) (->> (read-entry cfg input entry nil) (decode-tokens-lib) (validate-tokens-lib)))) @@ -811,7 +854,7 @@ (defn- read-file-tokens-status [{:keys [::bfc/input ::entries-index] :as cfg} file-id tokens-lib] (when-let [{:keys [entry]} (first (get-in entries-index [:tokens-status (str file-id)]))] - (events/tap :progress {:section :tokens-status :file-id file-id}) + (tap-progress cfg {:section :tokens-status :file-id file-id}) (->> (read-entry cfg input entry nil) (cfo/names->ids tokens-lib) (decode-tokens-status) @@ -833,19 +876,21 @@ (defn- read-file-pages [{:keys [::bfc/input ::entries-index] :as cfg} file-id] - (->> (get-in entries-index [:pages (str file-id)]) - (keep (fn [{:keys [id entry]}] - (let [page (->> (read-entry cfg input entry) - (decode-page)) - page (dissoc page :options)] - (events/tap :progress {:section :page :id id :file-id file-id}) - (when (= id (:id page)) - (let [objects (read-file-shapes cfg file-id id)] - (assoc page :objects objects)))))) - (sort-by :index) - (reduce (fn [result {:keys [id] :as page}] - (assoc result id (dissoc page :index))) - (d/ordered-map)))) + (let [entries (get-in entries-index [:pages (str file-id)])] + (->> (d/enumerate entries) + (keep (fn [[index {:keys [id entry]}]] + (let [page (->> (read-entry cfg input entry) + (decode-page)) + page (dissoc page :options)] + (tap-progress cfg {:section :page :id id :file-id file-id + :current (inc index) :total (count entries)}) + (when (= id (:id page)) + (let [objects (read-file-shapes cfg file-id id)] + (assoc page :objects objects)))))) + (sort-by :index) + (reduce (fn [result {:keys [id] :as page}] + (assoc result id (dissoc page :index))) + (d/ordered-map))))) (defn- read-file-thumbnails [{:keys [::bfc/input ::entries-index] :as cfg} file-id] @@ -886,8 +931,11 @@ :plugin-data plugin-data})) (defn- import-file - [{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}] - (let [file-id' (bfc/lookup-index file-id) + [{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} + {file-id :id file-name :name} + position] + (let [cfg (with-outer-counter cfg :files position) + file-id' (bfc/lookup-index file-id) file (read-file cfg file-id) media (read-file-media cfg file-id) thumbnails (read-file-thumbnails cfg file-id)] @@ -899,16 +947,19 @@ :version (:version file) ::l/sync? true) + (tap-progress cfg {:section :file :file-id file-id}) + (vswap! bfc/*state* update :index bfc/update-index media :id) - (doseq [item media] + (doseq [[index item] (d/enumerate media)] (let [params (-> item (update :id bfc/lookup-index) (assoc :file-id file-id') (d/update-when :media-id bfc/lookup-index) (d/update-when :thumbnail-id bfc/lookup-index))] - (events/tap :progress {:section :media :id (:id params) :file-id file-id}) + (tap-progress cfg {:section :media :id (:id params) :file-id file-id + :current (inc index) :total (count media)}) (l/dbg :hint "inserting media object" :file-id (str file-id') @@ -921,7 +972,7 @@ (db/insert! conn :file-media-object params ::db/on-conflict-do-nothing? (::bfc/overwrite cfg)))) - (doseq [item thumbnails] + (doseq [[index item] (d/enumerate thumbnails)] (let [media-id (bfc/lookup-index (:media-id item)) object-id (-> (assoc item :file-id file-id') (cth/fmt-object-id)) @@ -935,13 +986,12 @@ :media-id (str media-id) ::l/sync? true) - (events/tap :progress {:section :thumbnail :file-id file-id :object-id object-id}) + (tap-progress cfg {:section :thumbnail :file-id file-id :object-id object-id + :current (inc index) :total (count thumbnails)}) (db/insert! conn :file-tagged-object-thumbnail params ::db/on-conflict-do-nothing? true))) - (events/tap :progress {:section :file :file-id file-id}) - (let [data (-> (read-file-data cfg file-id (:tokens-source file)) (d/without-nils) (assoc :id file-id') @@ -970,26 +1020,26 @@ (defn- import-file-relations [{:keys [::db/conn ::manifest ::bfc/timestamp] :as cfg}] - (events/tap :progress {:section :relations}) - (doseq [[file-id libr-id] (:relations manifest)] + (let [relations (:relations manifest)] + (doseq [[index [file-id libr-id]] (d/enumerate relations)] + (tap-progress cfg {:section :relations + :current (inc index) :total (count relations)}) - (let [file-id (bfc/lookup-index file-id) - libr-id (bfc/lookup-index libr-id)] + (let [file-id (bfc/lookup-index file-id) + libr-id (bfc/lookup-index libr-id)] - (when (and file-id libr-id) - (l/dbg :hint "create file library link" - :file-id (str file-id) - :lib-id (str libr-id) - ::l/sync? true) - (let [rel-params {:file-id file-id - :library-file-id libr-id}] - (db/insert! conn :file-library-rel rel-params) - (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))))) + (when (and file-id libr-id) + (l/dbg :hint "create file library link" + :file-id (str file-id) + :lib-id (str libr-id) + ::l/sync? true) + (let [rel-params {:file-id file-id + :library-file-id libr-id}] + (db/insert! conn :file-library-rel rel-params) + (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))))))) (defn- import-storage-objects [{:keys [::bfc/input ::entries-index ::bfc/timestamp] :as cfg}] - (events/tap :progress {:section :storage-objects}) - ;; IMPORTANT: we strongly do not reuse the main connection that can ;; run inside a transaction because the storage upload process can ;; fail in the middle of uploading and leave garbage on the underlying @@ -998,9 +1048,12 @@ ;; what the storage subsystem registers in other parallel ;; transaction (let [storage (sto/resolve cfg) - entries (:objects entries-index)] + entries (:objects entries-index) + total (count entries)] - (doseq [{:keys [id entry]} entries] + (doseq [[index {:keys [id entry]}] (d/enumerate entries)] + (tap-progress cfg {:section :storage-objects + :current (inc index) :total total}) (let [object (-> (read-entry cfg input entry) (decode-storage-object) (d/update-when :content-type bfc/normalize-content-type) @@ -1182,12 +1235,15 @@ (vswap! bfc/*state* update :index assoc old-lib-id library-id))) (let [files (get manifest :files) - file-ids (reduce (fn [result file] + total (count files) + file-ids (reduce (fn [result [index file]] (let [name' (get file :name) - file (assoc file :name name')] - (conj result (import-file cfg file)))) + file (assoc file :name name')] + (conj result (import-file cfg file + {:current (inc index) + :total total})))) [] - files) + (d/enumerate files)) ;; Build map of file-id to file-name for resolution files-info (into {} (map (fn [file-id manifest-file] [file-id (:name manifest-file)]) @@ -1244,7 +1300,7 @@ bfc/*reference-file* ref-file] (import-storage-objects cfg) - (import-file cfg file) + (import-file cfg file {:current 1 :total 1}) (invalidate-thumbnails cfg file-id) (bfm/apply-pending-migrations! cfg) @@ -1299,7 +1355,7 @@ (cfeat/check-supported-features! features))) - (events/tap :progress {:section :manifest}) + (tap-progress cfg {:section :manifest}) (binding [bfc/*state* (volatile! {:media [] :index {}})] (if (::bfc/file-id cfg) @@ -1352,6 +1408,11 @@ (vreset! ab true) nil) + (catch clojure.lang.ExceptionInfo cause + (if (= :interrupt (:type (ex-data cause))) + (vreset! ab true) + (throw cause))) + (catch Throwable cause (vreset! cs cause) (vreset! ab true) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 10a97b7bee..a4375c2184 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -79,6 +79,7 @@ :jobs-lease (ct/duration {:minutes 30}) :jobs-retention (ct/duration {:days 7}) + :jobs-user-ttl (ct/duration {:days 7}) :jobs-request-timeout (ct/duration {:minutes 2}) :media-max-file-size (* 1024 1024 30) ; 30MiB @@ -101,6 +102,8 @@ :quotes-upload-sessions-per-profile 5 :quotes-upload-chunks-per-session 20 + :quotes-export-jobs-per-profile 10 + :quotes-import-jobs-per-profile 10 :upload-max-chunk-size (* 1024 1024 30) ; 30MiB ;; SSRF protection @@ -196,6 +199,7 @@ [:file-clean-delay {:optional true} ::ct/duration] [:jobs-lease {:optional true} ::ct/duration] [:jobs-retention {:optional true} ::ct/duration] + [:jobs-user-ttl {:optional true} ::ct/duration] [:jobs-request-timeout {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] [:default-blob-version {:optional true} ::sm/int] @@ -215,6 +219,7 @@ [:worker-default-parallelism {:optional true} ::sm/int] [:worker-webhook-parallelism {:optional true} ::sm/int] [:worker-cron-parallelism {:optional true} ::sm/int] + [:worker-binfile-parallelism {:optional true} ::sm/int] [:database-password {:optional true} [:maybe :string]] [:database-uri {:optional true} ::sm/uri] @@ -233,6 +238,8 @@ [:quotes-font-variants-per-team {:optional true} ::sm/int] [:quotes-comment-threads-per-file {:optional true} ::sm/int] [:quotes-comments-per-file {:optional true} ::sm/int] + [:quotes-export-jobs-per-profile {:optional true} ::sm/int] + [:quotes-import-jobs-per-profile {:optional true} ::sm/int] [:quotes-snapshots-per-file {:optional true} ::sm/int] [:quotes-snapshots-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-team {:optional true} ::sm/int] @@ -474,6 +481,15 @@ (or (c/get config :jobs-retention) (ct/duration {:days 7}))) +(defn get-jobs-user-ttl + "How long a user-facing job, and the artifact it owns, is kept before + the jobs GC deletes the row and marks the object for the storage GC. + Not the same as `:jobs-retention`, which only sweeps terminal internal + rows." + [] + (or (c/get config :jobs-user-ttl) + (ct/duration {:days 7}))) + (defn get "A configuration getter. Helps code be more testable." ([key] diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index e2c41d0886..e75f599fdc 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -150,14 +150,22 @@ [request] (some? (request-profile-id request))) -(defn- tempfile-owner-match? - "Check if the request's profile-id matches the tempfile's stored owner. - Returns true if no profile-id was stored (legacy objects)." +(def ^:private owner-scoped-buckets + "Buckets whose objects belong to a profile: a request from anybody else + must not reach them." + #{sto/tempfile-bucket sto/job-resource-bucket}) + +(defn- owner-match? + "Check that the request belongs to the profile that owns the object. + A missing owner is only tolerated in `tempfile`, where objects predate + the metadata; the `job-resource` bucket always carries one, so an + object without it fails closed." [obj request] - (let [stored-profile-id (:profile-id (meta obj)) - request-profile-id (request-profile-id request)] - (or (nil? stored-profile-id) - (= stored-profile-id request-profile-id)))) + (let [stored (-> obj meta :profile-id) + owner (request-profile-id request) + legacy? (= sto/tempfile-bucket (-> obj meta :bucket))] + (or (and legacy? (nil? stored)) + (= stored owner)))) (defn- serve-object-measured "Serve `obj`, recording one asset metric per outcome. A failure is @@ -175,7 +183,9 @@ "Handler that serves storage objects by id. For non-public buckets (e.g. profile), requires authentication via session cookie or access token. - For tempfile bucket, also requires ownership (profile-id match)." + For the owner-scoped buckets (tempfile, job-resource), also requires + ownership: the request must come from the profile the object belongs + to." [{:keys [::sto/storage] :as cfg} request] (let [id (get-id request) obj (sto/get-object storage id)] @@ -193,8 +203,8 @@ ;; The response stays 404 to avoid leaking existence, but the ;; metric records the internal 401 outcome. - (and (= (-> obj meta :bucket) sto/tempfile-bucket) - (not (tempfile-owner-match? obj request))) + (and (contains? owner-scoped-buckets (-> obj meta :bucket)) + (not (owner-match? obj request))) (do (emit-asset! cfg "by-id" obj 401) {::yres/status 404}) diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index 0fc7c385bd..4c1420876e 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -60,14 +60,13 @@ (partial write! output) (partial pu/close! output))] (try - (binding [events/*channel* channel] - (let [result (handler)] - (events/tap :end result))) - - (catch Throwable cause - (let [result (errors/handle' cause request)] - (events/tap channel :error result))) - + (binding [events/*sink* channel] + (try + (let [result (handler)] + (events/tap :end result)) + (catch Throwable cause + (let [result (errors/handle' cause request)] + (events/tap :error result))))) (finally (sp/close! channel) (px/await! listener))))))})) diff --git a/backend/src/app/jobs.clj b/backend/src/app/jobs.clj index 849ecb3784..be36f25549 100644 --- a/backend/src/app/jobs.clj +++ b/backend/src/app/jobs.clj @@ -9,9 +9,11 @@ Every job is defined as a job-def map returned by the `ig/init-key` of its module: {::name, ::schema, ::handler, ::decoder, ::validator} — decoder and - validator are precompiled at init time. The registry of job-defs is plain - integrant wiring (::jobs/defs); submit, dispatch and management consume it - by reference. + validator are precompiled at init time. A job-def may also carry the + optional metadata {::family, ::resource-role}, which the consumers of the + job (quotes, storage helpers) read from the registry: the substrate never + reads them. The registry of job-defs is plain integrant wiring (::jobs/defs); + submit, dispatch and management consume it by reference. Two execution modes are provided: - `submit` (durable): validates + JSON-encodes params and inserts a row @@ -27,8 +29,10 @@ on its loop, otherwise the dispatcher marks it `aborted` (a system-side terminal state, like `cancelled` but set by the dispatcher instead of a user: never retried, kept for triage and reported with an error - log) while its side effects continue. `heartbeat` also accepts an - optional `progress` report, which is stored as a `progress` row of + log) while its side effects continue. A beat that finds the job no + longer active raises an `:interrupt`, an internal signal that the task + should stop the work in course, never an error. `heartbeat` also accepts + an optional `progress` report, which is stored as a `progress` row of `job_event`: progress is durable history, not a mutable column, and needs no Redis. @@ -37,7 +41,7 @@ follow the job without polling the database. Reserved ledger columns (`profile_id`, `error`, `result`, `resource_id`, - `expires_at`) are only written by `submit` (profile and resource + `expires_at`) are only written by `submit` (profile, resource and expiry references) and by the terminal writers; `submit` never infers them from `params`." (:require @@ -82,14 +86,20 @@ whether to schedule again. A handler has no use for the counter, and the attempt number would lie anyway: the `noop` retry strategy runs again without incrementing it, so a durable \"which execution is this\" - number would need a second counter that nothing bounds." + number would need a second counter that nothing bounds. + + `profile-id` is the owner of a user-facing job and nil for an internal + one. It travels here so a handler can revalidate permissions and check + who owns the resource it is working on without a second query." [:map {:closed true :title "job-context"} [:id ::sm/uuid] [:name ::sm/text] [:label [:maybe ::sm/text]] ;; technical reference kept for garbage collection - [:resource-id [:maybe ::sm/uuid]]]) + [:resource-id [:maybe ::sm/uuid]] + ;; owner of a user-facing job; nil for an internal job + [:profile-id [:maybe ::sm/uuid]]]) (def check-context "Validate a handler context; raises with the malli explanation when it @@ -97,7 +107,7 @@ (sm/check-fn schema:context)) (defn make-context - "Build the handler context from a job row: exactly the four keys a + "Build the handler context from a job row: exactly the five keys a handler may see, and nothing else. The result is a plain map, not the database row, and it is not modified afterwards. @@ -107,7 +117,8 @@ (check-context {:id (:id job) :name (:name job) :label (if (str/blank? (:label job)) nil (:label job)) - :resource-id (:resource-id job)})) + :resource-id (:resource-id job) + :profile-id (:profile-id job)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; JOB DEFINITIONS (registry) @@ -123,6 +134,12 @@ (def ^:private schema:job-def [:map {:title "job-def"} [::name schema:job-name] + ;; Optional metadata of the job family, read from the registry by the + ;; consumers that need it (quotes, storage helpers, a future job + ;; listing). `app.jobs` itself never reads them: they are not part of + ;; the execution contract. + [::family {:optional true} ::sm/keyword] + [::resource-role {:optional true} ::sm/keyword] [::schema any?] ;; every handler is [context params]. A job-def already closes over its ;; own dependencies, so nothing else is handed to it: a handler cannot @@ -211,9 +228,9 @@ (def ^:private sql:insert-new-job "insert into job (id, name, tenant, params, queue, label, priority, - max_retries, profile_id, resource_id, created_at, - modified_at, scheduled_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + max_retries, profile_id, resource_id, expires_at, + created_at, modified_at, scheduled_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id") ;; `tenant` is part of the match, not an extra: without it this DELETE @@ -243,7 +260,11 @@ [::profile-id {:optional true} ::sm/uuid] ;; Technical reference (storage object) kept for garbage collection. It ;; is a column, never part of `params`, and it is never inferred from it. - [::resource-id {:optional true} ::sm/uuid]]) + [::resource-id {:optional true} ::sm/uuid] + ;; Retention of the job: when it is set, the jobs GC deletes the row + ;; (and reclaims its resource) once the instant has passed. User-facing + ;; jobs set it at creation; internal jobs leave it nil. + [::expires-at {:optional true} ::ct/inst]]) (def check-options (sm/check-fn schema:options)) @@ -264,13 +285,17 @@ encodes them as plain JSON and inserts a row into the `job` table. Fire-and-forget: returns the job id immediately. + The optional ledger columns (`::profile-id`, `::resource-id` and + `::expires-at`) are validated and stored as columns; nothing is inferred + from `params`. + NOTE: the dedupe DELETE and the INSERT run atomically: joined to the caller's transaction when the cfg provides `::db/conn`, wrapped in their own transaction otherwise. Concurrent cross-backend submissions can, in rare race conditions, produce duplicated 'new' rows (accepted risk, see prod-infra documentation)." [cfg {:keys [::params ::name ::delay ::queue ::priority ::max-retries - ::dedupe ::label ::profile-id ::resource-id] + ::dedupe ::label ::profile-id ::resource-id ::expires-at] :or {delay 0 queue :default priority 100 max-retries 3 label ""} :as options}] @@ -322,7 +347,7 @@ :replace (or deleted 0)) (db/exec-one! conn [sql:insert-new-job id job-name tenant payload queue label priority max-retries - profile-id resource-id + profile-id resource-id expires-at now now scheduled-at])))] ;; Both statements always run inside db/tx-run!: joined to the ;; caller's transaction when the cfg provides a connection, @@ -348,7 +373,10 @@ (defn- decode-row [row] - (decode-json-col row :params)) + (-> row + (decode-json-col :params) + (decode-json-col :result) + (decode-json-col :error))) (defn get-job "Retrieve the job row (with the raw JSON params decoded to a plain map)." @@ -387,52 +415,93 @@ (def ^:private known-retry-reasons #{"backoff" "noop"}) -(def ^:private max-stage-length 250) +(def ^:private max-progress-key-length + "Longest accepted `:stage` or counter key. They name a vocabulary, they + are not a place for data, so the cap only keeps a runaway key out of the + log." + 64) + +(def schema:progress-counter + "Counter of one scope of a job: `current` is how many units are done and + `total` how many there are, when the worker knows it." + [:map {:closed true + :title "job-progress-counter"} + [:current ::sm/int] + [:total {:optional true} ::sm/int]]) (def schema:progress - "Progress report of a job: `current` is mandatory, `total` and `stage` are - optional, and no other key is accepted. `stage` is a short human label, - not a place for exception messages or params." + "Progress milestone of a job: the activity in course and the counters of + the scopes around it, each one under its own key. + + `:stage` is a stable keyword the client resolves (a translation, an + icon...), never a display string, and `:counters` is open on purpose: a + worker reports the scopes it knows and names the counter of the activity + after its own stage, so a client can render `counters[stage]` without a + table of equivalences. Neither is a place for names, params or exception + text: the payload is keywords and integers only." [:map {:closed true :title "job-progress"} - [:current ::sm/int] - [:total {:optional true} ::sm/int] - [:stage {:optional true} ::sm/text]]) + [:stage ::sm/keyword] + [:counters {:optional true} [:map-of ::sm/keyword schema:progress-counter]]]) (def check-progress - "Validate a progress report against its schema." + "Validate the shape of a progress milestone." (sm/check-fn schema:progress)) +(defn- check-counter + "Check the range rules of one counter: a non-negative `current` and a + positive `total` never lower than its `current`." + [key counter] + (when (neg? (:current counter)) + (ex/raise :type :validation + :code :invalid-progress + :hint "progress counter current must not be negative" + :counter key + :progress counter)) + (when-let [total (:total counter)] + (when (or (not (pos? total)) + (> (:current counter) total)) + (ex/raise :type :validation + :code :invalid-progress + :hint "progress counter total must be positive and not lower than current" + :counter key + :progress counter)))) + (defn validate-progress - "Check the range rules a schema cannot express: non-negative `current`, - positive `total`, `current` never greater than `total` and a short - `stage`." + "Check what the schema cannot express: non-negative counters, a positive + total never lower than its `current`, and stage and counter keys short + enough to be a name and not a payload." [progress] (let [progress (check-progress progress)] - (when (neg? (:current progress)) - (ex/raise :type :validation - :code :invalid-progress - :hint "progress current must not be negative" - :progress progress)) - (when-let [total (:total progress)] - (when (or (not (pos? total)) - (> (:current progress) total)) + (doseq [key (cons (:stage progress) (keys (:counters progress)))] + (when (> (count (name key)) max-progress-key-length) (ex/raise :type :validation :code :invalid-progress - :hint "progress total must be positive and not lower than current" + :hint (str "progress key must not be longer than " + max-progress-key-length " characters") + :key key :progress progress))) - (when (and (:stage progress) - (> (count (:stage progress)) max-stage-length)) - (ex/raise :type :validation - :code :invalid-progress - :hint (str "progress stage must not be longer than " - max-stage-length " characters") - :progress progress)) + (doseq [[key counter] (:counters progress)] + (check-counter key counter)) progress)) +(defn decode-progress + "Read back a stored progress payload as a Clojure map: `:stage` and the + counter keys come back as the strings the database holds, so they are + keywordized here, and a payload that is already a map is returned as is." + [progress] + (when (some? progress) + (-> (cond-> progress + (db/pgobject? progress) + (db/decode-json-pgobject)) + (d/update-when :stage keyword) + (d/update-when :counters + (fn [counters] + (into {} (map (fn [[k v]] [(keyword k) v])) counters)))))) + (def ^:private event-payload-keys {"start" #{:attempt} - "progress" #{:current :total :stage} + "progress" #{:stage :counters} "retry" #{:attempt :reason} "end" #{:outcome}}) @@ -490,7 +559,7 @@ :payload payload :created-at created-at})) -(defn insert-event +(defn- insert-event "Insert a `job_event` row, then count it and notify after the commit. Must be called inside a transaction that owns the job row. When the job @@ -501,7 +570,11 @@ The counter also runs after the commit, so a rolled back transaction never inflates it." [{:keys [::mbus/msgbus] :as cfg} job-id kind payload] - (let [payload (validate-event-payload kind payload) + (assert (db/connection-map? cfg) + "expected cfg with valid connection: insert-event must run inside a transaction") + (assert (some? db/*after-commit-context*) + "expected transaction context: insert-event must run inside a transaction") + (let [payload (validate-event-payload kind payload) connectable (db/get-connectable cfg) profile-id (:profile-id (db/exec-one! connectable [sql:job-event-owner job-id]))] (when (and profile-id @@ -543,8 +616,14 @@ ;; HEARTBEAT / PROGRESS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private heartbeat-interval (ct/duration {:seconds 60})) -(def ^:private progress-interval (ct/duration {:seconds 1})) +(def ^:private heartbeat-interval + ;; one second, down from a minute: a heavy job that was cancelled must + ;; be seen as gone at its next beat. The price is one `modified_at` + ;; touch per beating job per second (60x the old rate): cheap while the + ;; only heavy beater runs alone on its queue, revisit if concurrent + ;; beating jobs ever grow. + (ct/duration {:seconds 1})) +(def ^:private progress-interval (ct/duration {:millis 200})) (def ^:private prune-threshold 10000) (def ^:private prune-window (ct/duration {:hours 1})) @@ -597,6 +676,12 @@ (swap! heartbeats dissoc job-id) (swap! progresses dissoc job-id)) +(def ^:private active-statuses + "The statuses of a job that is still on its way. It mirrors the filter + of the heartbeat and progress writes below: a row outside this set is no + longer active and nothing can report to it." + #{"new" "scheduled" "running" "retry"}) + (def ^:private sql:touch-heartbeat "UPDATE job SET modified_at = ? @@ -632,6 +717,27 @@ 1) 0)))) +(defn check-active + "Raise an `:interrupt` when the job is no longer active. + + A job is active while its status is one of `active-statuses`; a row + that already reached a terminal state, or that no longer exists, is + not. The status is read from the row, because a write can only tell + that it matched no row, not why. + + The interrupt is an internal signal, not an error: the caller is a task + that must stop the work in course. `heartbeat` calls this when one of + its writes finds the job gone, and a caller can also ask explicitly, + which is what the binfile adapter does before and after its run." + [cfg job-id] + (let [job (get-job cfg job-id)] + (when-not (and (some? job) (contains? active-statuses (:status job))) + (ex/raise :type :interrupt + :code :job-interrupted + :hint "the job is no longer active" + :job-id job-id + :status (or (:status job) "gone"))))) + (defn heartbeat "Keep a running job alive and, optionally, report its progress. @@ -642,14 +748,18 @@ - `:progress` optional progress report (see `schema:progress`). - `::force?` internal: skips only the progress throttle. - Returns the number of durable writes performed, so 0 means the job is - terminal, has no job context, or the throttle did not allow a write. + Returns the number of durable writes performed, so 0 means there was + nothing to write: no job context, or the throttle did not allow it. A + write that finds the job no longer active raises an `:interrupt` (see + `check-active`) instead of counting zero, so a task in course stops at + its next beat. - The `modified_at` touch is throttled to ~60s and the progress event to - ~1s: external workers report progress as sparse milestones, while - handlers may beat on every iteration. Both writes go through the - connection pool (`::db/pool` on the cfg), never the caller transaction, - so a beat survives a rollback of the surrounding work. + Both writes go through the connection pool (`::db/pool` on the cfg), + never the caller transaction, so a beat survives a rollback of the + surrounding work: the `modified_at` touch is throttled to ~1s while + progress events go out at up to ~200ms, so the user sees movement + without hammering the row. External workers report progress as sparse + milestones, while handlers may beat on every iteration. Like every other function here the cfg is a cfg: a bare pool or connection is a caller bug and is not accommodated. @@ -662,7 +772,7 @@ forced path used by the management API propagates it so the external worker can retry." [cfg & {:keys [job-id progress] :as options}] - (let [job-id (or job-id (get cfg ::job-id) *job-id*) + (let [job-id (or job-id (get cfg ::job-id) *job-id*) ;; a malformed report is a caller bug: never swallow it progress (some-> progress validate-progress)] (when (uuid? job-id) @@ -671,20 +781,31 @@ force? (boolean (::force? options)) writes (volatile! 0)] (when (should-write? heartbeats job-id now heartbeat-interval) - (vswap! writes + (touch-job connectable job-id now))) + (let [n (touch-job connectable job-id now)] + ;; the touch is conditional on the row still being active: a + ;; write that matched no row is what raises the interrupt + (when (zero? n) + (check-active cfg job-id)) + (vswap! writes + n))) (when (and (some? progress) (or force? (should-write? progresses job-id now progress-interval))) - (let [store #(report-progress cfg job-id progress)] - (if force? - (vswap! writes + (store)) - (try - (vswap! writes + (store)) - (catch Throwable cause - (l/err :hint "unable to persist job progress" - :tenant (cf/get :tenant) - :job-id (str job-id) - :cause cause)))))) + (let [store #(report-progress cfg job-id progress) + n (if force? + (store) + (try + (store) + (catch Throwable cause + (l/err :hint "unable to persist job progress" + :tenant (cf/get :tenant) + :job-id (str job-id) + :cause cause) + 0)))] + ;; the raise stays outside the catch above: an interrupt is + ;; not a transient failure and must not be swallowed with it + (when (zero? n) + (check-active cfg job-id)) + (vswap! writes + n))) (int @writes))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -976,14 +1097,17 @@ "UPDATE job SET status='cancelled', modified_at=? WHERE id=? - AND status IN ('new','scheduled','retry') + AND status IN ('new','scheduled','retry','running') RETURNING id") (defn cancel - "Cancel a pending job (new/scheduled/retry) and record the `end` event. + "Cancel a pending or running job (new/scheduled/retry/running) and + record the `end` event. - A running or terminal job is left untouched and writes no event. Returns - the number of affected rows." + A running job stops at its next heartbeat: the beat finds no active + row and raises an `:interrupt`, which the runner treats as a normal + stop while the handler rolls back. A terminal job is left untouched + and writes no event. Returns the number of affected rows." [cfg job-id] (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") (let [job (get-job cfg job-id) diff --git a/backend/src/app/jobs/storage.clj b/backend/src/app/jobs/storage.clj new file mode 100644 index 0000000000..dd33252cb7 --- /dev/null +++ b/backend/src/app/jobs/storage.clj @@ -0,0 +1,152 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.jobs.storage + "Storage helpers for the objects a job owns through `job.resource_id`. + + A job resource lives in the `job-resource` bucket and belongs to the + profile that owns the job, so only that profile can read it back (the + asset route checks the owner, not just the session). + + The job row is what keeps the object alive: `storage-gc-touched` + freezes it while a row references it and deletes it once no row does, + and the jobs GC marks it as touched when it removes the row. The object + carries no expiry of its own: `expired-at` is stored as `deleted_at`, + which makes the object unreadable from birth, so the retention of the + artifact is the retention of its job." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.time :as ct] + [app.config :as cf] + [app.db :as db] + [app.storage :as sto] + [app.storage.tmp :as tmp])) + +(defn- resource-uri + "The url a user downloads the artifact from. A string, because the + descriptor ends up in the JSON result of the job." + [id] + (cf/get-public-uri "assets/by-id/" (str id))) + +(defn put-resource + "Store an artifact of a job in the `job-resource` bucket and return the + descriptor that identifies it. + + Named options: + + - `:content` the artifact, an `app.storage/content` value built from a + temporary file or from bytes in memory + - `:filename` the name the user will see + - `:mtype` the content type of the artifact + + `profile-id` is the owner of the object: the profile that owns the job, + for both the package a job consumes and the artifact it produces. + + The object is marked as touched right away, so an artifact that no job + row ever references (a crash between the upload and the completion) is + reclaimed by the storage GC instead of leaking; while a row references + it, `storage-gc-touched` freezes it." + [cfg profile-id {:keys [content filename mtype]}] + (assert (uuid? profile-id) "expected the profile that owns the resource") + (assert (string? mtype) "expected the content type of the resource") + (let [storage (sto/resolve cfg) + object (sto/put-object! storage + {::sto/content content + ::sto/touched-at (ct/now) + :bucket sto/job-resource-bucket + :profile-id profile-id + :content-type mtype})] + {:resource-id (:id object) + :resource-uri (resource-uri (:id object)) + :filename filename + :mtype mtype + :size (:size object)})) + +(defn- check-input + "The object a job consumes must exist, be a job resource and belong to + the profile that owns the job. Raises `:not-found` otherwise: a handler + never reads another profile's package." + [context object] + (when (or (nil? object) + (not= sto/job-resource-bucket (:bucket (meta object))) + (not= (:profile-id context) (:profile-id (meta object)))) + (ex/raise :type :not-found + :code :job-resource-not-found + :hint "the input of the job is not available" + :job-id (:id context) + :resource-id (:resource-id context))) + object) + +(defn load-input + "Download the package a job consumes to a local temporary file and + return its path, ready for the binfile reader. + + An object that was released or expired is already invisible to + `sto/get-object`, so it lands in the same `:not-found` as a missing + one." + [cfg context] + (let [storage (sto/resolve cfg) + resource-id (:resource-id context) + object (check-input context + (when (uuid? resource-id) + (sto/get-object storage resource-id)))] + (if-let [input (sto/get-object-data storage object)] + (tmp/tempfile-from input) + (ex/raise :type :not-found + :code :job-resource-not-found + :hint "the input of the job is not readable" + :job-id (:id context) + :resource-id resource-id)))) + +(defn release-resource + "Release a job resource. + + The object is marked as deleted and, when it belongs to a job, the + row stops pointing at it in the same transaction: a row never + references an object that is already gone, and the foreign key is not + what ends up clearing the reference. A resource no job owns (the + package of a creation that never submitted) is released with a nil + job id, and then no row is touched at all. + + Best effort: a failure is logged and never changes the terminal state + of the job, and releasing without a resource is a no-op. Returns true + when the object was marked as deleted or a stale reference was + cleared." + ([cfg resource-id] + (release-resource cfg resource-id nil)) + ([cfg resource-id job-id] + (when (uuid? resource-id) + (try + (db/tx-run! + cfg + (fn [tx-cfg] + (let [storage (sto/resolve tx-cfg ::db/reuse-conn true) + deleted? (sto/del-object! storage resource-id) + ;; the job no longer owns the package: the pointer goes with + ;; it, so clearing it does not depend on the object being + ;; physically deleted later + cleared? (when (some? job-id) + (db/update! tx-cfg :job + {:resource-id nil :modified-at (ct/now)} + ["id = ? AND resource_id = ?" + job-id resource-id] + {::db/return-keys [:id]}))] + (or deleted? (some? cleared?))))) + (catch Throwable cause + (l/wrn :hint "unable to release the input of a job" + :job-id (str job-id) + :resource-id (str resource-id) + :cause cause) + false))))) + +(defn release-input + "Release the package a job consumed. + + Delegates to `release-resource` with the id of the job: the object + and the pointer are dropped together. Best effort, like there." + [cfg context] + (release-resource cfg (:resource-id context) (:id context))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 419dd88175..87d0b31f29 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -590,6 +590,8 @@ :file-gc (ig/ref :app.tasks.file-gc/job-def) :offload-file-data (ig/ref :app.tasks.offload-file-data/job-def) :objects-gc (ig/ref :app.tasks.objects-gc/job-def) + :export-binfile (ig/ref :app.tasks.export-binfile/job-def) + :import-binfile (ig/ref :app.tasks.import-binfile/job-def) :storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def) :storage-gc-touched (ig/ref ::sto.gc-touched/job-def) :storage-pending-gc (ig/ref ::sto.pending-gc/job-def) @@ -642,6 +644,19 @@ ::sto/storage (ig/ref ::sto/storage) ::mtx/metrics (ig/ref ::mtx/metrics)} + :app.tasks.export-binfile/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics) + ;; the job belongs to a profile, so its events are published + ::mbus/msgbus (ig/ref ::mbus/msgbus)} + + :app.tasks.import-binfile/job-def + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage) + ::mtx/metrics (ig/ref ::mtx/metrics) + ::mbus/msgbus (ig/ref ::mbus/msgbus)} + :app.storage.gc-deleted/job-def {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage) @@ -840,6 +855,17 @@ ::db/pool (ig/ref ::db/pool) ::mbus/msgbus (ig/ref ::mbus/msgbus)} + ;; the heavy work of a user job does not share the default runner + [::binfile ::wrk/runner] + {::wrk/parallelism (cf/get :worker-binfile-parallelism 1) + ::wrk/queue :binfile + ::wrk/tenant (cf/get :tenant) + ::rds/client (ig/ref ::rds/client) + ::jobs/defs (ig/ref ::jobs/defs) + ::mtx/metrics (ig/ref ::mtx/metrics) + ::db/pool (ig/ref ::db/pool) + ::mbus/msgbus (ig/ref ::mbus/msgbus)} + [::cron ::wrk/runner] {::wrk/parallelism (cf/get :worker-cron-parallelism 2) ::wrk/queue :cron diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index cf80790b2e..d1b5247b95 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -379,6 +379,7 @@ 'app.rpc.commands.files-update 'app.rpc.commands.files-snapshot 'app.rpc.commands.files-thumbnails + 'app.rpc.commands.jobs 'app.rpc.commands.ldap 'app.rpc.commands.management 'app.rpc.commands.media diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index de0d3faadd..67163c1568 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -10,7 +10,6 @@ [app.binfile.common :as bfc] [app.binfile.v1 :as bf.v1] [app.binfile.v3 :as bf.v3] - [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uri :as u] @@ -47,19 +46,12 @@ [:embed-assets {:optional true} ::sm/boolean]]) (defn- export-binfile - [{:keys [::sto/storage] :as cfg} {:keys [type file-id include-libraries embed-assets]}] - (let [output (tmp/tempfile*) - ;; Convert legacy boolean flags to unified export-type - export-type (cond - (some? type) type - (true? include-libraries) :include-libraries - (true? embed-assets) :merge-libraries - :else :detach-libraries)] + [{:keys [::sto/storage] :as cfg} {:keys [file-id] :as params}] + (let [output (tmp/tempfile*)] (try - (-> cfg - (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/export-type export-type) + (-> (bfc/export-cfg cfg {:ids #{file-id} + :export-type (bfc/resolve-export-type params)}) (bf.v3/export-files! output)) (let [data (sto/content output) @@ -98,22 +90,13 @@ :project-id project-id) cfg - (-> cfg - (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) - (assoc ::bfc/project-id project-id) - (assoc ::bfc/profile-id profile-id) - (assoc ::bfc/team-id (:id team)) - (assoc ::bfc/name name) - (assoc ::bfc/import-max-binary-entry-size (cf/get :binfile-import-max-binary-entry-size)) - (assoc ::bfc/import-max-text-entry-size (cf/get :binfile-import-max-text-entry-size)) - (assoc ::bfc/import-max-text-total-size (cf/get :binfile-import-max-text-total-size)) - (assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))) + (bfc/import-cfg cfg {:profile-id profile-id + :project-id project-id + :team team + :name name + :input (:path file)}) - input-path (:path file) - owned? (some? upload-id) - - cfg - (assoc cfg ::bfc/input input-path) + owned? (some? upload-id) result (try @@ -126,7 +109,7 @@ :version version}))) (finally (when owned? - (fs/delete input-path))))] + (fs/delete (:path file)))))] (db/update! pool :project {:modified-at (ct/now)} diff --git a/backend/src/app/rpc/commands/jobs.clj b/backend/src/app/rpc/commands/jobs.clj new file mode 100644 index 0000000000..d1aa5b85cb --- /dev/null +++ b/backend/src/app/rpc/commands/jobs.clj @@ -0,0 +1,355 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.rpc.commands.jobs + "Creation of durable, user-facing jobs. + + One command per job type: the body carries the business params its + job-def declares and nothing else, and the job-def schema is the single + place that says what those params are. Creating a job never runs it: it + freezes what the job will do, and the work belongs to the runner." + (:require + [app.binfile.common :as bfc] + [app.binfile.v3 :as bf.v3] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.storage :as js] + [app.loggers.audit :as-alias audit] + [app.loggers.webhooks :as-alias webhooks] + [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] + [app.rpc.commands.files :as files] + [app.rpc.commands.media :as media] + [app.rpc.commands.projects :as projects] + [app.rpc.doc :as-alias doc] + [app.rpc.quotes :as quotes] + [app.storage :as sto] + [app.tasks.export-binfile :as export-binfile] + [app.tasks.import-binfile :as import-binfile] + [app.util.services :as sv] + [datoteka.fs :as fs])) + +(def ^:private schema:create-export-binfile-job + "The `.penpot` package of the files frozen in the params, produced by + the backend runner; the business params are what the `:export-binfile` + job-def declares." + [:map {:title "create-export-binfile-job" :closed true} + [:params export-binfile/schema:params]]) + +(def ^:private schema:create-import-binfile-job + "The `.penpot` package the caller uploaded, imported into a project by + the backend runner; the business params are what the `:import-binfile` + job-def declares for creation." + [:map {:title "create-import-binfile-job" :closed true} + [:params import-binfile/schema:create-params] + ;; the package arrives as a chunked upload and never as a path the + ;; caller names: see `assemble-upload` + [:upload-id ::sm/uuid]]) + +(def ^:private schema:job-summary + "What the caller gets when a job is created: enough to follow it, and + nothing about the work it will do." + [:map {:title "job-summary"} + [:id ::sm/uuid] + [:status ::sm/text] + [:name ::sm/text] + [:created-at ::ct/inst] + [:expires-at [:maybe ::ct/inst]]]) + +(defn- resolve-job-def + "The job the caller named, refused when it is not a job of `family`: the + registry is the source of truth, and a name that is not one of its jobs + is rejected before anything is read or stored. + + The job-def is returned because the caller needs it right away: it is + what decodes and validates the business params." + [cfg family name] + (let [job-def (get (jobs/get-defs cfg) (keyword name))] + (when-not (= family (::jobs/family job-def)) + (ex/raise :type :validation + :code :not-a-job-of-the-family + :hint "the job name is not a registered job of that family" + :name (d/name name) + :family (d/name family))) + job-def)) + +(defn- get-job-summary + [cfg job-id] + (let [job (jobs/get-job cfg job-id)] + {:id (:id job) + :status (jobs/get-user-status (:status job)) + :name (:name job) + :created-at (:created-at job) + :expires-at (:expires-at job)})) + +(defn- submit-job + "Create the job row with what every user job shares: the queue its + job-def routes the heavy work to (`::jobs/queue-name`, `:binfile` when + the def says nothing), no automatic retry and the expiry of the ledger." + [cfg job-def name params profile-id & {:keys [resource-id]}] + (jobs/submit cfg (cond-> {::jobs/name name + ::jobs/params params + ::jobs/queue (or (::jobs/queue-name job-def) + :binfile) + ::jobs/profile-id profile-id + ::jobs/max-retries 0 + ::jobs/expires-at (ct/plus (ct/now) + (cf/get-jobs-user-ttl))} + (some? resource-id) + (assoc ::jobs/resource-id resource-id)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; EXPORT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(sv/defmethod ::create-export-binfile-job + "Create a durable job that exports a set of files as a `.penpot` + package. + + Its params are `:file-ids`, the set of files to export, and + `:export-type`, how the libraries of those files are handled: what + the job-def declares, and nothing more. There is no default for the + type, because a caller that does not say it would get a package that + is not the one it asked for. + + The job is created, not run: the caller follows it by its id. The + read permission of every file is checked here so a refusal arrives + synchronously, and the handler checks it again before reading." + {::doc/added "2.20" + ::webhooks/event? true + ::sm/params schema:create-export-binfile-job + ::sm/result schema:job-summary} + [cfg {:keys [::rpc/profile-id] :as envelope}] + (let [job-def (resolve-job-def cfg :export :export-binfile) + ;; the RPC layer decoded the params against the job-def schema + ;; before the command ran + params (jobs/validate-params job-def (:params envelope)) + file-ids (:file-ids params)] + + (when (empty? file-ids) + (ex/raise :type :validation + :code :no-files-to-export + :hint "expected at least one file to export")) + + (doseq [file-id file-ids] + (files/check-read-permissions! cfg profile-id file-id)) + + (quotes/check! cfg {::quotes/id ::quotes/export-jobs-per-profile + ::quotes/profile-id profile-id}) + + (let [summary (get-job-summary cfg (submit-job cfg job-def :export-binfile + params profile-id))] + (with-meta summary + {::audit/props {:job-id (:id summary) + :files (count file-ids) + :export-type (d/name (:export-type params))}})))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMPORT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- assemble-upload + "The package the caller uploaded, assembled into a local file that + belongs to this call. + + The command only takes a chunked upload: a plain file upload would mean + reading a path the caller named, and the server must never read a file + it did not write itself." + [cfg profile-id upload-id] + (db/tx-run! cfg media/assemble-chunks profile-id upload-id)) + +(defn- resolve-version + "The version of the package: what the caller says, or what the header + of the file says." + [path version] + (or version + (case (bfc/parse-file-format path) + :binfile-v3 3 + 1))) + +(sv/defmethod ::create-import-binfile-job + "Create a durable job that imports a `.penpot` package into a project. + + The package arrives as a chunked upload, which the server assembles + itself: the command never reads a path the caller names. The package is + then checked and stored as the resource of the job, so any worker can + consume it by `resource_id`. The job is created, not run, and the caller + follows it by its id." + {::doc/added "2.20" + ::webhooks/event? true + ::sm/params schema:create-import-binfile-job + ::sm/result schema:job-summary + ;; assembling the upload and storing the package happens here, in the + ;; request: the same limit the legacy import command declares + ::climit/id [[:create-import-binfile-job/by-profile ::rpc/profile-id] + [:create-import-binfile-job/global]]} + [cfg {:keys [::rpc/profile-id] :as envelope}] + (let [job-def (resolve-job-def cfg :import :import-binfile) + ;; the RPC layer decoded the params against the create schema + ;; before the command ran + given (:params envelope) + project-id (:project-id given)] + + ;; the edition permission is checked here so a refusal arrives + ;; synchronously and before the upload is assembled; the handler + ;; checks it again before writing + (projects/check-edition-permissions! cfg profile-id project-id) + + (quotes/check! cfg {::quotes/id ::quotes/import-jobs-per-profile + ::quotes/profile-id profile-id}) + + ;; the shape of what the caller sent is checked before the upload + ;; is assembled: the version is known good here (the create schema + ;; rejects one out of range, and the caller may omit it), so the + ;; merged map below only adds what the package says + (jobs/validate-params job-def (assoc given :version (or (:version given) 1))) + + (let [file (assemble-upload cfg profile-id (:upload-id envelope)) + path (:path file)] + + ;; everything that happens once the upload is on disk belongs to + ;; this try: a failure anywhere in it must not leave the temporary + ;; file behind + ;; + ;; the version and the manifest metadata are resolved here, in the + ;; command, and not in the job: only the creation path reads the + ;; package, and the job keeps what the audit will show + (try + (let [version (resolve-version path (:version given)) + manifest (when (= 3 version) + (bf.v3/get-manifest cfg path)) + ;; the manifest may name the tool that produced the package + ;; with either spelling: resolve it once, for the params and + ;; for the audit + referer (or (:referer manifest) (:refer manifest)) + params (cond-> (assoc given :version version) + manifest + (assoc :generated-by (:generated-by manifest) + :referer referer))] + + ;; the params are complete here: the version came from the + ;; caller or from the package, and the manifest filled the audit + (jobs/validate-params job-def params) + + (let [staged (js/put-resource cfg profile-id + {:content (sto/content path) + :filename "package.penpot" + :mtype "application/zip"})] + (try + (let [summary (get-job-summary cfg (submit-job cfg job-def :import-binfile params profile-id + :resource-id (:resource-id staged)))] + (with-meta summary + {::audit/props {:job-id (:id summary) + :generated-by (:generated-by manifest) + :referer referer}})) + + (catch Throwable cause + ;; the job never came to own the package, so it must not be + ;; left for the storage GC to find late + (js/release-resource cfg (:resource-id staged)) + (throw cause))))) + + (finally + ;; the assembled file belongs to this call: the package is + ;; already in storage by the time we get here, or the creation + ;; failed and it is of no use to anyone + (fs/delete path)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; READ +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:get-job + [:map {:title "get-job" :closed true} + [:id ::sm/uuid]]) + +(def ^:private schema:job-detail + "The summary of a job plus what only exists once it is over: the result + it produced or, when it failed, its public error." + [:map {:title "job-detail"} + [:id ::sm/uuid] + [:status ::sm/text] + [:name ::sm/text] + [:created-at ::ct/inst] + [:expires-at [:maybe ::ct/inst]] + [:result {:optional true} :any] + [:error {:optional true} :any]]) + +(defn- get-job-detail + "The job the caller owns, as the answer of `get-job`. A job that does + not exist and a job of another profile get the same answer: the caller + cannot tell them apart." + [cfg profile-id job-id] + (let [job (jobs/get-job cfg job-id)] + (when-not (and (some? job) (= profile-id (:profile-id job))) + (ex/raise :type :not-found + :code :job-not-found + :hint "the job does not exist or belongs to another profile" + :job-id job-id)) + (cond-> {:id (:id job) + :status (jobs/get-user-status (:status job)) + :name (:name job) + :created-at (:created-at job) + :expires-at (:expires-at job)} + (some? (:result job)) + (assoc :result (:result job)) + + (some? (:error job)) + (assoc :error (jobs/decode-job-error (:error job)))))) + +(sv/defmethod ::get-job + "Read a job the caller created: its state and, once it is over, what it + produced or why it failed. + + The result of a finished job is the plain JSON the row holds, because a + result has no schema of its own like the params do: a uuid comes back as + the text it is on disk. The progress and the history of the job are not + part of the answer: a client that follows a job takes its progress from + the websocket, and the event log still has no read surface." + {::doc/added "2.20" + ::sm/params schema:get-job + ::sm/result schema:job-detail} + [cfg {:keys [::rpc/profile-id] :as params}] + (get-job-detail cfg profile-id (:id params))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; CANCEL +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:cancel-job + [:map {:title "cancel-job" :closed true} + [:id ::sm/uuid]]) + +(sv/defmethod ::cancel-job + "Cancel a job the caller created, whether it is still waiting or + already running. + + A running job stops at its next beat and rolls its work back; a job + that already finished between the request and the update is answered + with the state it is in, never an error. Like every other command + here, a job of another profile is indistinguishable from one that does + not exist." + {::doc/added "2.20" + ::sm/params schema:cancel-job + ::sm/result schema:job-summary} + [cfg {:keys [::rpc/profile-id] :as params}] + (let [job-id (:id params) + job (jobs/get-job cfg job-id)] + (when-not (and (some? job) (= profile-id (:profile-id job))) + (ex/raise :type :not-found + :code :job-not-found + :hint "the job does not exist or belongs to another profile" + :job-id job-id)) + (jobs/cancel cfg job-id) + ;; the answer names the outcome of this call, not the collapsed + ;; user status: a job the caller just cancelled reads "cancelled" + (assoc (get-job-summary cfg job-id) + :status (:status (jobs/get-job cfg job-id))))) diff --git a/backend/src/app/rpc/management/jobs.clj b/backend/src/app/rpc/management/jobs.clj index ab1561e704..1e71acb101 100644 --- a/backend/src/app/rpc/management/jobs.clj +++ b/backend/src/app/rpc/management/jobs.clj @@ -16,6 +16,7 @@ which mandates a valid shared-key before dispatching to these methods. If the route resolver changes, these methods must be updated accordingly." (:require + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] [app.jobs :as jobs] @@ -60,8 +61,8 @@ (def ^:private schema:report-job-progress-params [:map {:title "report-job-progress-params"} ;; The progress schema is the same one the jobs substrate validates: - ;; `current` is mandatory, `total` and `stage` are optional, and no - ;; other key is accepted. + ;; `:stage` is mandatory (a keyword of the worker vocabulary) and + ;; `:counters` is an optional map of `scope -> {:current :total?}`. [:job-id ::sm/uuid] [:progress jobs/schema:progress]]) @@ -78,13 +79,25 @@ ;; A lost race (row already terminal) reports :skip so the worker ;; stops retrying a report that can never land, mirroring claim-job. ;; The forced route propagates database errors: the external worker - ;; must be able to retry a report it was not sure about. - (if (pos? (jobs/heartbeat cfg - :job-id job-id - :progress progress - ::jobs/force? true)) - {:action :run} - {:action :skip})) + ;; must be able to retry a report it was not sure about. The interrupt + ;; the beat raises when the job is no longer active is caught here for + ;; the same reason, and it never reaches the HTTP error mapping: it is + ;; an internal signal, not a failure of the request. + (let [result (ex/try! (jobs/heartbeat cfg + :job-id job-id + :progress progress + ::jobs/force? true))] + (cond + (ex/exception? result) + (if (= :interrupt (:type (ex-data result))) + {:action :skip} + (throw result)) + + (pos? result) + {:action :run} + + :else + {:action :skip}))) ;; ---- RPC METHOD: COMPLETE-JOB diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 2cbe336a8d..049728c3a0 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -7,6 +7,7 @@ (ns app.rpc.quotes "Penpot resource usage quotes." (:require + [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] @@ -617,6 +618,61 @@ team-id team-id team-id team-id team-id team-id]) (generic-check!))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; QUOTE: BINFILES JOBS PER PROFILE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:jobs-per-profile + [:map [::profile-id ::sm/uuid]]) + +(def ^:private valid-jobs-per-profile-quote? + (sm/lazy-validator schema:jobs-per-profile)) + +;; Only the jobs that are still on their way count: a terminal job is not +;; pending work anymore, and the jobs GC removes it. The tenant filters +;; like every other query of the substrate: instances share one database +;; and one profile can have work in more than one of them. +(def ^:private + sql:get-jobs-per-profile + "SELECT count(*) AS total + FROM job + WHERE tenant = ? + AND profile_id = ? + AND status IN ('new', 'scheduled', 'running', 'retry') + AND name = ANY(?)") + +(defn- names-of-family + "The registered names of a family of jobs. The quote counts what the + registry knows, so a new job of the family is covered without touching + this file." + [cfg family] + (->> (jobs/get-defs cfg) + (vals) + (filter #(= family (::jobs/family %))) + (map #(d/name (::jobs/name %))) + (vec))) + +(defn- check-jobs-quote + [cfg family config-key] + (let [{:keys [::db/conn ::profile-id ::target]} cfg] + (assert (valid-jobs-per-profile-quote? cfg) "invalid quote parameters") + (-> cfg + (assoc ::default (cf/get config-key Integer/MAX_VALUE)) + (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) + (assoc ::count-sql [sql:get-jobs-per-profile + (cf/get :tenant) + profile-id + (db/create-array conn "text" (names-of-family cfg family))]) + (generic-check!)))) + +(defmethod check-quote ::export-jobs-per-profile + [cfg] + (check-jobs-quote cfg :export :quotes-export-jobs-per-profile)) + +(defmethod check-quote ::import-jobs-per-profile + [cfg] + (check-jobs-quote cfg :import :quotes-import-jobs-per-profile)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index 6c7a87762b..f73fd3557e 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -248,11 +248,15 @@ backend' (impl/resolve-backend storage backend) bucket (:bucket mdata) + ;; `job-resource` objects belong to one profile, so they are + ;; never shared: a dedup hit would hand the artifact of one + ;; profile to the job of another. dedupable? (and (::deduplicate? params) (:hash mdata) (some? bucket) (not= tempfile-bucket bucket) - (not= upload-session-bucket bucket)) + (not= upload-session-bucket bucket) + (not= job-resource-bucket bucket)) hit (when dedupable? (get-database-object-by-hash pool backend bucket (:hash mdata)))] diff --git a/backend/src/app/storage/schema.clj b/backend/src/app/storage/schema.clj index 17e858b972..50a354d12f 100644 --- a/backend/src/app/storage/schema.clj +++ b/backend/src/app/storage/schema.clj @@ -53,7 +53,8 @@ "organization" #{:organization-id} tempfile-bucket #{} upload-session-bucket #{} - job-resource-bucket #{} + ;; read back only by the profile that owns them + job-resource-bucket #{:profile-id} "file-data" #{:file-id :id} "file-data-fragment" #{} "file-change" #{}}) diff --git a/backend/src/app/tasks/export_binfile.clj b/backend/src/app/tasks/export_binfile.clj new file mode 100644 index 0000000000..eaa1ba0f55 --- /dev/null +++ b/backend/src/app/tasks/export_binfile.clj @@ -0,0 +1,94 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.tasks.export-binfile + "The `:export-binfile` job: it produces the `.penpot` artifact of the + set of files frozen in its params." + (:require + [app.binfile.jobs :as bfj] + [app.common.schema :as sm] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.storage :as js] + [app.metrics :as mtx] + [app.rpc.commands.files :as files] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [datoteka.fs :as fs] + [integrant.core :as ig])) + +(def schema:params + "Business params of an export: the files to export, frozen when the job + was created, and how their libraries are handled." + [:map {:title "export-binfile-params" :closed true} + [:file-ids [::sm/set ::sm/uuid]] + [:export-type [::sm/one-of #{:include-libraries :merge-libraries + :detach-libraries :link-later}]]]) + +(def ^:private artifact + "What the user sees of the package an export produces. A single name + describes a set of files, so it does not come from any of them." + {:filename "export.penpot" + :mtype "application/zip"}) + +(def schema:result + "What the job stores when the export succeeds: where the artifact can be + downloaded from and what it is." + [:map {:title "export-binfile-result" :closed true} + [:resource-uri ::sm/text] + [:filename ::sm/text] + [:mtype ::sm/text] + [:size ::sm/int]]) + +(def ^:private check-result + (sm/check-fn schema:result + :hint "invalid result of an export job" + :type :validation + :code :invalid-result)) + +(defn execute-export + "Plain handler, importable and testable without integrant. + + The read permission is checked again here, and not only when the job was + created: it can be revoked while the job waits in its queue. The artifact + is stored as the resource of the job, and the completion envelope carries + its descriptor as the business result and its id for the runner to + associate." + [cfg context params] + (let [profile-id (:profile-id context) + file-ids (:file-ids params)] + + (doseq [file-id file-ids] + (files/check-read-permissions! cfg profile-id file-id)) + + (let [output (tmp/tempfile* :suffix ".penpot")] + (try + (bfj/export-files cfg context {:ids file-ids + :export-type (:export-type params) + :output output}) + (let [resource (js/put-resource cfg profile-id + (assoc artifact :content (sto/content output)))] + {:result (check-result (dissoc resource :resource-id)) + :resource-id (:resource-id resource)}) + (finally + (fs/delete output)))))) + +(defmethod ig/assert-key ::job-def + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool") + (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided") + (assert (mtx/metrics? (::mtx/metrics params)) "expected valid metrics")) + +(defmethod ig/init-key ::job-def + [_ cfg] + {::jobs/name :export-binfile + ::jobs/family :export + ::jobs/resource-role :output + ::jobs/schema schema:params + ::jobs/handler (fn [context params] + (execute-export cfg context params)) + ::jobs/decoder (sm/decoder schema:params sm/json-transformer) + ::jobs/validator (sm/validator schema:params)}) diff --git a/backend/src/app/tasks/import_binfile.clj b/backend/src/app/tasks/import_binfile.clj new file mode 100644 index 0000000000..5dc286db08 --- /dev/null +++ b/backend/src/app/tasks/import_binfile.clj @@ -0,0 +1,126 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.tasks.import-binfile + "The `:import-binfile` job: it imports the package the job owns as a + resource into the project frozen in its params." + (:require + [app.binfile.jobs :as bfj] + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.storage :as js] + [app.metrics :as mtx] + [app.rpc.commands.projects :as projects] + [app.rpc.commands.teams :as teams] + [app.storage :as sto] + [datoteka.fs :as fs] + [integrant.core :as ig])) + +(def schema:params + "Business params of an import: where the package goes, its name, the + format version and the manifest metadata kept for audit." + [:map {:title "import-binfile-params" :closed true} + [:project-id ::sm/uuid] + [:name [:or [:string {:max 250}] + [:map-of ::sm/uuid [:string {:max 250}]]]] + [:version [:enum 1 3]] + [:generated-by {:optional true} [:maybe ::sm/text]] + [:referer {:optional true} [:maybe ::sm/text]]]) + +(def schema:create-params + "The business params of an import as the creation command receives + them: where the package goes and its name. The version is optional + because the command reads it from the package header when the caller + does not say it, and the manifest metadata kept for audit is filled by + the command, never sent by the caller." + [:map {:title "import-binfile-create-params" :closed true} + [:project-id ::sm/uuid] + [:name [:or [:string {:max 250}] + [:map-of ::sm/uuid [:string {:max 250}]]]] + [:version {:optional true} [:enum 1 3]]]) + +(def schema:result + "What the job stores when the import succeeds: the files it created and + how the libraries of the package were resolved." + [:map {:title "import-binfile-result" :closed true} + [:file-ids [:vector ::sm/uuid]] + [:resolution {:optional true} :map] + [:name [:or [:string {:max 250}] + [:map-of ::sm/uuid [:string {:max 250}]]]] + [:version [:enum 1 3]]]) + +(def ^:private check-result + (sm/check-fn schema:result + :hint "invalid result of an import job" + :type :validation + :code :invalid-result)) + +(defn execute-import + "Plain handler, importable and testable without integrant. + + The edition permission is checked again here, and not only when the job + was created: it can be revoked while the job waits in its queue. The + core runs inside a transaction, so a cancellation detected between units + rolls the whole import back. + + The package is the input of the job: it is released and its temporary + copy deleted once the job is terminal, whatever the outcome. A crash is + covered by the storage GC." + [cfg context params] + (let [profile-id (:profile-id context) + project-id (:project-id params) + input (js/load-input cfg context)] + + (try + (projects/check-edition-permissions! cfg profile-id project-id) + + (let [team (or (teams/get-team cfg + :profile-id profile-id + :project-id project-id) + (ex/raise :type :not-found + :code :project-not-found + :hint "the destination project is not available" + :profile-id profile-id + :project-id project-id)) + result (db/tx-run! cfg + (fn [tx-cfg] + ;; the core joins this transaction: it + ;; is the one that decides whether the + ;; whole import is kept or rolled back + (bfj/import-files tx-cfg context + {:profile-id profile-id + :project-id project-id + :team team + :name (:name params) + :input input + :version (:version params)})))] + + (check-result (assoc result + :name (:name params) + :version (:version params)))) + + (finally + (fs/delete input) + (js/release-input cfg context))))) + +(defmethod ig/assert-key ::job-def + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool") + (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided") + (assert (mtx/metrics? (::mtx/metrics params)) "expected valid metrics")) + +(defmethod ig/init-key ::job-def + [_ cfg] + {::jobs/name :import-binfile + ::jobs/family :import + ::jobs/resource-role :input + ::jobs/schema schema:params + ::jobs/handler (fn [context params] + (execute-import cfg context params)) + ::jobs/decoder (sm/decoder schema:params sm/json-transformer) + ::jobs/validator (sm/validator schema:params)}) diff --git a/backend/src/app/util/events.clj b/backend/src/app/util/events.clj index ffbd96a47c..1c82380943 100644 --- a/backend/src/app/util/events.clj +++ b/backend/src/app/util/events.clj @@ -5,9 +5,15 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.events - "A generic asynchronous events notifications subsystem; used mainly - for mark event points in functions and be able to attach listeners - to them. Mainly used in http.sse for progress reporting." + "Event points of a run: the core marks what it does with `tap`, and + whoever runs it decides where those marks go. + + The sink bound for the run decides how each event travels: a function + is called inline, on the same thread, while a channel receives the + event for a listener of its own. The jobs adapter binds a function + that turns taps into heartbeats, so an `:interrupt` it raises aborts + the run right there; the SSE stream binds a channel instead, and its + listener writes what arrives to the response." (:refer-clojure :exclude [run!]) (:require [app.common.exceptions :as ex] @@ -15,19 +21,29 @@ [promesa.exec :as px] [promesa.exec.csp :as sp])) -(def ^:dynamic *channel* nil) +(def ^:dynamic *sink* nil) (defn tap - ([type data] - (when-let [channel *channel*] - (sp/put! channel [type data]) - nil)) - ([channel type data] - (when channel - (sp/put! channel [type data]) - nil))) + "Mark an event point of the run in course with `[type data]`. + + With no sink bound nothing happens, so untracked runs pay a single + nil check per event point. A function sink is called inline and any + exception it raises propagates to the producer: that is how an + `:interrupt` from a heartbeat stops the run at its next event point. + A channel sink receives the event for its listener instead." + [type data] + (when-let [sink *sink*] + (if (fn? sink) + (sink [type data]) + (sp/put! sink [type data])) + nil)) (defn spawn-listener + "Consume the events of `channel` on a thread of its own, until it + closes. + + `on-close` runs once the listener stops. A failure of `on-event` is + logged and stops the listener." [channel on-event on-close] (assert (sp/chan? channel) "expected active events channel") @@ -39,22 +55,14 @@ (let [result (ex/try! (on-event event))] (if (ex/exception? result) (do - (l/wrn :hint "unexpected exception" :cause result) + (l/err :hint "unexpected exception on calling-on-event of spawn-listener" + :cause result) (sp/close! channel)) (recur))))) (finally - (on-close))))) - -(defn run-with! - "A high-level facility for to run a function in context of event - emiter." - [f on-event] - - (binding [*channel* (sp/chan :buf 32)] - (let [listener (spawn-listener *channel* on-event (constantly nil))] - (try - (f) - (finally - (sp/close! *channel*) - (px/await! listener)))))) + (try + (on-close) + (catch Exception cause + (l/err :hint "unexpected exception on calling on-close of spawn-listener" + :cause cause))))))) diff --git a/backend/src/app/worker/runner.clj b/backend/src/app/worker/runner.clj index f5506f0450..2202722c0d 100644 --- a/backend/src/app/worker/runner.clj +++ b/backend/src/app/worker/runner.clj @@ -163,9 +163,22 @@ (throw cause)) (catch Throwable cause (let [edata (ex-data cause)] - (if (and (< (:retry-num job) - (:max-retries job)) - (= ::wrk/retry (:type edata))) + (cond + ;; the interrupt is the signal that the job is no longer active: + ;; a normal procedure, so it is not reported as an error and it + ;; never fails or retries a row that is already terminal + (= :interrupt (:type edata)) + (binding [l/*context* (-> (cf/logging-context) + (assoc :params job))] + (l/inf :hint "interrupted" + :tenant tenant + :job-id (str (:job-id edata)) + :status (:status edata)) + {:status "interrupted"}) + + (and (< (:retry-num job) + (:max-retries job)) + (= ::wrk/retry (:type edata))) (cond-> {:status "retry" :error cause} (ct/duration? (:delay edata)) (assoc :delay-ms (inst-ms (:delay edata))) @@ -174,6 +187,8 @@ (= ::wrk/noop (:strategy edata)) (assoc :inc-by 0)) + + :else (do (l/err :hint "unhandled exception on job" ::l/context (assoc (cf/logging-context) :params job) @@ -288,9 +303,12 @@ (process-result [{:keys [status] :as result}] (ex/try! (case status - "retry" (handle-job-retry result) - "failed" (handle-job-failure result) - "completed" (handle-job-completion result) + "retry" (handle-job-retry result) + "failed" (handle-job-failure result) + "completed" (handle-job-completion result) + ;; the job is no longer active: there is nothing to write, + ;; the row is already terminal and whoever stopped it did + "interrupted" nil (throw (IllegalArgumentException. (str "invalid status received: '" status "'")))))) diff --git a/backend/test/backend_tests/binfile_jobs_test.clj b/backend/test/backend_tests/binfile_jobs_test.clj new file mode 100644 index 0000000000..7b4619c96c --- /dev/null +++ b/backend/test/backend_tests/binfile_jobs_test.clj @@ -0,0 +1,403 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.binfile-jobs-test + "The binfile work of a job: the shared adapter that runs it with its + progress reported as job events, and the two job-defs that expose it." + (:require + [app.binfile.common :as bfc] + [app.binfile.jobs :as bfj] + [app.binfile.v3 :as v3] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.storage :as js] + [app.rpc.commands.teams :as teams] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [app.util.events :as events] + [backend-tests.helpers :as th] + [clojure.java.io :as jio] + [clojure.test :as t] + [datoteka.fs :as fs] + [datoteka.io :as io])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private fixture + "A real penpot export, the same fixture the binfile tests read." + "backend_tests/test_files/svg-attrs-camel-case.penpot") + +(defn- fixture-path + [] + (-> fixture io/resource jio/file)) + +(defn- import-fixture! + "A file in the default project of the profile, imported from the + fixture through the core." + [profile] + (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input (fixture-path)) + (v3/import-files!))) + +(defn- make-job! + "A job row as the substrate writes it: running, owned by a profile and + holding a resource when the test needs one." + [profile-id resource-id] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name "export-binfile" + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :resource-id resource-id + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + +(defn- job-cfg + "The system cfg as the runner hands it to a handler. The job id is NOT + bound here: the adapter takes it from the context, because the progress + bridge runs on a thread of its own." + [] + th/*system*) + +(defn- job-context + [job-id] + (jobs/make-context (jobs/get-job th/*system* job-id))) + +(defn- progress-events + "The progress events of a job, oldest first, with decoded payloads." + [job-id] + (->> (th/db-exec! ["SELECT payload FROM job_event + WHERE job_id = ? AND kind = 'progress' + ORDER BY created_at ASC, id ASC" + job-id]) + (mapv #(jobs/decode-progress (:payload %))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; ADAPTER +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest export-through-the-adapter-produces-the-artifact + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile) nil) + output (tmp/tempfile* :suffix ".penpot")] + + (bfj/export-files (job-cfg) (job-context job-id) + {:ids #{file-id} + :export-type :detach-libraries + :output output}) + + (t/testing "the artifact is a penpot file with its manifest" + (t/is (fs/exists? output)) + (t/is (= [file-id] (mapv :id (:files (v3/get-manifest th/*system* output)))))) + + (t/testing "the progress of the core became milestones of the job" + (let [events (progress-events job-id)] + (t/is (pos? (count events))) + (t/is (every? keyword? (map :stage events))) + (t/testing "the first one is the file in course, with its position" + (let [event (first events)] + (t/is (= :files (:stage event))) + (t/is (= {:current 1 :total 1} (get-in event [:counters :files]))))) + (t/testing "and nothing else: the event is the stage and the counters" + (t/is (every? #(<= (count (keys %)) 2) events))))))) + +(t/deftest import-through-the-adapter-imports-the-package + (let [profile (th/create-profile* 1) + team (teams/get-team th/*system* + :profile-id (:id profile) + :project-id (:default-project-id profile)) + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content (fixture-path)) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (make-job! (:id profile) (:resource-id staged)) + context (job-context job-id) + result (bfj/import-files (job-cfg) context + {:profile-id (:id profile) + :project-id (:default-project-id profile) + :team team + :name "imported" + :input (js/load-input th/*system* context) + :version 3})] + + (t/testing "the package is imported in the destination project" + (t/is (= 1 (count (:file-ids result))))) + + (t/testing "the job reported its progress" + (t/is (pos? (count (progress-events job-id))))))) + +(t/deftest the-adapter-composes-a-milestone-per-tap + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile) nil) + reports (atom [])] + + ;; the substrate throttles the durable events, so the whole stream of + ;; milestones is only visible with the writes intercepted + (with-redefs [jobs/heartbeat (fn [_cfg & {:as options}] + (swap! reports conj (:progress options)) + 1)] + (bfj/export-files (job-cfg) (job-context job-id) + {:ids #{file-id} + :export-type :detach-libraries + :output (tmp/tempfile* :suffix ".penpot")})) + + (t/testing "the file opens the run with its position" + (let [event (first @reports)] + (t/is (= :files (:stage event))) + (t/is (= {:current 1 :total 1} (get-in event [:counters :files]))))) + + (t/testing "the stages inside the file carry it as context, with their own counter" + (let [event (first (filter #(= :pages (:stage %)) @reports))] + (t/is (= {:current 1 :total 1} (get-in event [:counters :files]))) + (t/is (pos? (get-in event [:counters :pages :current]))) + (t/is (pos? (get-in event [:counters :pages :total]))))) + + (t/testing "a stage outside a file carries no file context" + (let [events (filter #(= :storage-objects (:stage %)) @reports)] + (t/is (pos? (count events))) + (t/is (every? #(nil? (get-in % [:counters :files])) events)))) + + (t/testing "and every milestone is a stage and its counters, and nothing else" + (t/is (every? #(<= (count (keys %)) 2) @reports))))) + +(t/deftest a-tap-carries-the-outer-counters-in-course + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile) nil) + taps (atom [])] + + ;; the taps themselves, before the adapter turns them into events + (with-redefs [events/tap (fn [_type data] (swap! taps conj data))] + (bfj/export-files (job-cfg) (job-context job-id) + {:ids #{file-id} + :export-type :detach-libraries + :output (tmp/tempfile* :suffix ".penpot")})) + + (t/testing "the tap that opens the file carries the outer counters" + (let [tap (first (filter #(= :file (:section %)) @taps))] + (t/is (= {:files {:current 1 :total 1}} (:outer-counters tap))))) + + (t/testing "every tap inside the file carries them as context" + (let [inner (filter #(some? (:file-id %)) @taps)] + (t/is (pos? (count inner))) + (t/is (every? #(= {:files {:current 1 :total 1}} (:outer-counters %)) inner)))) + + (t/testing "a tap outside a file carries no outer counters" + (let [outside (filter #(= :storage-object (:section %)) @taps)] + (t/is (pos? (count outside))) + (t/is (every? #(nil? (:outer-counters %)) outside)))))) + +(t/deftest a-milestone-merges-outer-counters-with-its-own + (let [milestone @#'bfj/milestone] + (t/testing "outer scopes survive next to the counter of the stage" + (t/is (= {:stage :pages + :counters {:teams {:current 1 :total 1} + :projects {:current 2 :total 2} + :files {:current 1 :total 4} + :pages {:current 2 :total 5}}} + (milestone {:section :page + :current 2 :total 5 + :outer-counters {:teams {:current 1 :total 1} + :projects {:current 2 :total 2} + :files {:current 1 :total 4}}})))) + (t/testing "a file tap needs no own counter, the outer one is enough" + (t/is (= {:stage :files + :counters {:files {:current 1 :total 2}}} + (milestone {:section :file + :outer-counters {:files {:current 1 :total 2}}})))))) + +(t/deftest entering-a-file-keeps-the-counters-of-the-outer-scopes + (let [cfg {::v3/outer-counters {:teams {:current 1 :total 1}}} + cfg' (@#'v3/with-outer-counter cfg :files {:current 2 :total 4})] + (t/is (= {:teams {:current 1 :total 1} + :files {:current 2 :total 4}} + (::v3/outer-counters cfg'))))) + +(t/deftest the-import-adapter-composes-the-same-milestones + (let [profile (th/create-profile* 1) + team (teams/get-team th/*system* + :profile-id (:id profile) + :project-id (:default-project-id profile)) + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content (fixture-path)) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (make-job! (:id profile) (:resource-id staged)) + context (job-context job-id) + reports (atom [])] + + (with-redefs [jobs/heartbeat (fn [_cfg & {:as options}] + (swap! reports conj (:progress options)) + 1)] + (bfj/import-files (job-cfg) context + {:profile-id (:id profile) + :project-id (:default-project-id profile) + :team team + :name "imported" + :input (js/load-input th/*system* context) + :version 3})) + + (t/testing "the run opens with the manifest, which has no units to count" + (let [event (first @reports)] + (t/is (= :manifest (:stage event))) + (t/is (nil? (:counters event))))) + + (t/testing "the file carries its position" + (let [event (first (filter #(= :files (:stage %)) @reports))] + (t/is (= {:current 1 :total 1} (get-in event [:counters :files]))))) + + (t/testing "a stage inside the file carries it as context, with its own counter" + (let [event (first (filter #(= :pages (:stage %)) @reports))] + (t/is (= {:current 1 :total 1} (get-in event [:counters :files]))) + (t/is (pos? (get-in event [:counters :pages :current]))) + (t/is (pos? (get-in event [:counters :pages :total]))))) + + (t/testing "and every milestone is a stage and its counters, and nothing else" + (t/is (every? #(<= (count (keys %)) 2) @reports))))) + +(t/deftest a-job-that-is-no-longer-active-stops-the-work + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile) nil) + context (job-context job-id)] + + (t/testing "a running job is active" + (t/is (nil? (bfj/check-active th/*system* context)))) + + (t/testing "a cancelled job is not" + (th/db-update! :job {:status "cancelled"} {:id job-id}) + (let [cause (try (bfj/check-active th/*system* context) nil + (catch Throwable raised raised))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (= "cancelled" (:status (ex-data cause)))))) + + (t/testing "so the export stops at its next progress point" + (let [output (tmp/tempfile* :suffix ".penpot") + cause (try + (bfj/export-files (job-cfg) context + {:ids #{file-id} + :export-type :detach-libraries + :output output}) + nil + (catch Throwable raised raised))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (zero? (count (progress-events job-id)))))) + + (t/testing "a job that already completed is not active either" + (th/db-update! :job {:status "completed"} {:id job-id}) + (let [cause (try (bfj/check-active th/*system* context) nil + (catch Throwable raised raised))] + (t/is (= :interrupt (th/ex-type cause))))) + + (t/testing "and neither is a job row that is gone" + (th/db-force-delete :job {:id job-id}) + (let [cause (try (bfj/check-active th/*system* context) nil + (catch Throwable raised raised))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (= "gone" (:status (ex-data cause)))))))) + +(t/deftest an-interrupted-beat-stops-the-core-at-its-tap + (let [steps (atom []) + context {:id (uuid/next)} + cause (ex-info "the job is no longer active" + {:type :interrupt :code :job-interrupted})] + (with-redefs [jobs/heartbeat (fn [& _] (throw cause))] + (let [raised (try (@#'bfj/with-progress th/*system* context + (fn [] + (swap! steps conj :before) + ;; the sink calls the beat + ;; inline, so its interrupt + ;; aborts the run here + (events/tap :progress {:section :file}) + (swap! steps conj :after))) + nil + (catch Throwable stopped stopped))] + (t/is (= :interrupt (:type (ex-data raised)))) + (t/is (= :job-interrupted (:code (ex-data raised)))) + (t/is (= [:before] @steps) + "the core stopped at its next tap, the step after never ran"))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JOB-DEFS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-binfile-jobs-follow-the-substrate-contract + (let [defs (::jobs/defs th/*system*)] + + (t/testing "the export job produces the resource of a user job" + (let [def (jobs/get-job-def defs :export-binfile)] + (t/is (= :export (::jobs/family def))) + (t/is (= :output (::jobs/resource-role def))) + (t/is (fn? (::jobs/handler def))))) + + (t/testing "the import job consumes the resource of the job" + (let [def (jobs/get-job-def defs :import-binfile)] + (t/is (= :import (::jobs/family def))) + (t/is (= :input (::jobs/resource-role def))) + (t/is (fn? (::jobs/handler def))))) + + (t/testing "the params are a closed contract" + (let [def (jobs/get-job-def defs :export-binfile) + file-id (uuid/next)] + (t/is (true? ((::jobs/validator def) + {:file-ids #{file-id} :export-type :detach-libraries}))) + (t/testing "a job id never travels in params" + (t/is (false? ((::jobs/validator def) + {:file-ids #{file-id} :export-type :detach-libraries + :job-id (uuid/next)})))))))) + +(t/deftest the-export-handler-runs-through-the-registry + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile) nil) + result (jobs/invoke (-> th/*system* + (assoc ::jobs/name :export-binfile) + (assoc ::jobs/params {:file-ids #{file-id} + :export-type :detach-libraries}) + (assoc ::jobs/context (job-context job-id))))] + + (t/testing "the handler answers with the completion envelope of the job" + (t/is (uuid? (:resource-id result))) + (t/is (some? (get-in result [:result :resource-uri])))) + + (t/testing "the handler reported progress without knowing the job id" + (t/is (pos? (count (progress-events job-id))))))) + +(t/deftest the-import-handler-runs-through-the-registry + (let [profile (th/create-profile* 1) + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content (fixture-path)) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (make-job! (:id profile) (:resource-id staged)) + result (jobs/invoke (-> th/*system* + (assoc ::jobs/name :import-binfile) + (assoc ::jobs/params {:project-id (:default-project-id profile) + :name "imported" + :version 3}) + (assoc ::jobs/context (job-context job-id))))] + + (t/testing "the package of the job was imported" + (t/is (pos? (count (:file-ids result))))) + (t/testing "the handler reported progress without knowing the job id" + (t/is (pos? (count (progress-events job-id))))))) diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index 863be00a5e..af06598962 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.config-test (:require + [app.common.time :as ct] [app.config :as cf] [clojure.test :as t])) @@ -51,11 +52,26 @@ (t/testing "schema decodes all runner parallelism keys (env strings to int)" (let [decoded (cf/decode-config {:worker-default-parallelism "4" :worker-webhook-parallelism "2" - :worker-cron-parallelism "3"})] + :worker-cron-parallelism "3" + :worker-binfile-parallelism "5"})] (t/is (= 4 (:worker-default-parallelism decoded))) (t/is (= 2 (:worker-webhook-parallelism decoded))) - (t/is (= 3 (:worker-cron-parallelism decoded))))) + (t/is (= 3 (:worker-cron-parallelism decoded))) + (t/is (= 5 (:worker-binfile-parallelism decoded))))) (t/testing "lookups resolve configured values (unnamespaced keys, as main uses)" (with-redefs [cf/config (assoc cf/config :worker-default-parallelism 4)] (t/is (= 4 (cf/get :worker-default-parallelism 1)))))) + +(t/deftest jobs-user-ttl + (t/testing "the env value is decoded as a duration" + (let [decoded (cf/decode-config {:jobs-user-ttl "72h"})] + (t/is (= (ct/duration {:days 3}) (:jobs-user-ttl decoded))))) + + (t/testing "the getter resolves the configured value" + (with-redefs [cf/config (assoc cf/config :jobs-user-ttl (ct/duration {:days 1}))] + (t/is (= (ct/duration {:days 1}) (cf/get-jobs-user-ttl))))) + + (t/testing "and falls back to a week" + (with-redefs [cf/config (dissoc cf/config :jobs-user-ttl)] + (t/is (= (ct/duration {:days 7}) (cf/get-jobs-user-ttl)))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index d89250eee3..d39e2a26a6 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -102,6 +102,22 @@ (fs/create-dir "/tmp/penpot") (next))))) +(defn- remove-job-runners + "Drops every `[ :app.worker/runner]` from the system. + + The tests drive the runner loop by hand (`wdisp/run-batch` plus + `run-worker-loop` over a system they build themselves), so a live runner + of any queue races them: it takes the job the test just dispatched and + completes it behind the test's back. Matching the key by shape, instead + of listing the known queues by hand, keeps a new queue from silently + bringing that race back (which is what happened to the `binfile` runner + this key list was missing)." + [system] + (apply dissoc system + (filter #(and (vector? %) + (= :app.worker/runner (second %))) + (keys system)))) + (defn init-system [next] (let [templates [{:id "test" @@ -132,10 +148,8 @@ :app.loggers.mattermost/reporter :app.loggers.database/reporter :app.worker/cron - :app.worker/dispatcher - [:app.main/default :app.worker/runner] - [:app.main/webhook :app.worker/runner] - [:app.main/cron :app.worker/runner])) + :app.worker/dispatcher) + (remove-job-runners)) _ (ig/load-namespaces system) system (-> (ig/expand system) (ig/init))] (try diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index a5e60e9965..1f6c473a51 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -887,6 +887,48 @@ response (assets/objects-handler cfg request)] (t/is (= 204 (::yres/status response))))) +;; ---------------------------------------------------------------- +;; Tests: objects-handler — job-resource bucket ownership +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-job-resource-owner-can-access + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + object (create-storage-object! storage "job-resource" "artifact" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id owner)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest objects-handler-job-resource-non-owner-gets-404 + ;; The artifact of a job belongs to the profile that owns the job. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + stranger (th/create-profile* 2) + object (create-storage-object! storage "job-resource" "artifact" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest objects-handler-job-resource-released-object-gets-404 + ;; Once the job is gone and the GC has reclaimed the artifact, the route + ;; has nothing to serve. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + object (create-storage-object! storage "job-resource" "artifact" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id owner)}] + (t/is (= 204 (::yres/status (assets/objects-handler cfg request)))) + (sto/del-object! storage (:id object)) + (t/is (= 404 (::yres/status (assets/objects-handler cfg request)))))) + ;; ---------------------------------------------------------------- ;; Tests: asset request metrics ;; ---------------------------------------------------------------- diff --git a/backend/test/backend_tests/jobs_metrics_test.clj b/backend/test/backend_tests/jobs_metrics_test.clj index af43e856f3..45f4b59725 100644 --- a/backend/test/backend_tests/jobs_metrics_test.clj +++ b/backend/test/backend_tests/jobs_metrics_test.clj @@ -298,10 +298,29 @@ (fn [{:keys [::db/conn]}] (jobs/heartbeat (assoc cfg ::db/conn conn) :job-id job-id - :progress {:current 1} + :progress {:stage :work} ::jobs/force? true))) (t/testing "the report is stored and counted, the caller rollback drops neither" (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event WHERE job_id = ? AND kind = 'progress'" job-id])))) (t/is (= (inc before) (counter-value metrics :jobs-events-total []))))))) + +(t/deftest the-binfile-queue-is-labelled-like-any-other + (let [metrics (make-metrics) + defs {:export-binfile {::jobs/name :export-binfile + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics metrics}] + + (jobs/submit cfg {::jobs/name :export-binfile + ::jobs/params {} + ::jobs/queue :binfile}) + + (t/testing "the queue of the job is its label, never other" + (t/is (= 1.0 (counter-value metrics :jobs-submitted ["export-binfile" "binfile"]))) + (t/is (= 0.0 (counter-value metrics :jobs-submitted ["export-binfile" "other"])))))) diff --git a/backend/test/backend_tests/jobs_storage_test.clj b/backend/test/backend_tests/jobs_storage_test.clj new file mode 100644 index 0000000000..3da1f29525 --- /dev/null +++ b/backend/test/backend_tests/jobs_storage_test.clj @@ -0,0 +1,205 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.jobs-storage-test + "The storage helpers of the unified jobs substrate: the objects a job + owns through `job.resource_id`." + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs.storage :as js] + [app.storage :as sto] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [datoteka.fs :as fs])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) + +(defn- get-storage + [] + (assoc (:app.storage/storage th/*system*) ::sto/backend :fs)) + +(defn- make-cfg + [] + {::db/pool th/*pool* + ::sto/storage (get-storage)}) + +(defn- get-object-row + "The `storage_object` row, deleted rows included." + [id] + (th/db-get :storage-object {:id id} {::db/remove-deleted false})) + +(defn- put-artifact + [cfg profile-id content] + (js/put-resource cfg profile-id {:content (sto/content content) + :filename "artifact.penpot" + :mtype "application/zip"})) + +(t/deftest put-resource-stores-the-artifact-for-its-owner + (let [cfg (make-cfg) + profile-id (:id (th/create-profile* 1)) + result (put-artifact cfg profile-id "penpot-bytes") + object (sto/get-object (get-storage) (:resource-id result)) + row (get-object-row (:resource-id result))] + + (t/testing "the object lives in the job-resource bucket" + (t/is (= sto/job-resource-bucket (:bucket (meta object))))) + + (t/testing "the owner and the content type travel in the metadata" + (t/is (= profile-id (:profile-id (meta object)))) + (t/is (= "application/zip" (:content-type (meta object))))) + + (t/testing "the object is readable: retention comes from the job row" + (t/is (nil? (:deleted-at row)))) + + (t/testing "it is touched right away so a crash leaves it reclaimable" + (t/is (some? (:touched-at row)))) + + (t/testing "the descriptor identifies the artifact" + (t/is (uuid? (:resource-id result))) + (t/is (= 12 (:size result))) + (t/is (= "artifact.penpot" (:filename result))) + (t/is (= "application/zip" (:mtype result))) + (t/is (str/includes? (str (:resource-uri result)) + (str (:resource-id result))))))) + +(t/deftest put-resource-requires-an-owner + (let [cfg (make-cfg)] + (t/testing "an artifact without an owner is a caller bug" + (t/is (thrown? Throwable + (js/put-resource cfg nil {:content (sto/content "x") + :filename "x.penpot" + :mtype "application/zip"})))))) + +(t/deftest load-input-gives-the-handler-a-local-path + (let [cfg (make-cfg) + profile-id (:id (th/create-profile* 1)) + staged (put-artifact cfg profile-id "package-bytes") + context {:id (uuid/next) + :resource-id (:resource-id staged) + :profile-id profile-id} + path (js/load-input cfg context)] + + (t/is (some? path)) + (t/is (= "package-bytes" (slurp (fs/file path)))))) + +(t/deftest load-input-refuses-an-input-of-another-profile + (let [cfg (make-cfg) + owner (:id (th/create-profile* 1)) + stranger (:id (th/create-profile* 2)) + staged (put-artifact cfg owner "package-bytes") + context {:id (uuid/next) + :resource-id (:resource-id staged) + :profile-id stranger}] + + (t/testing "a handler never reads a package that is not its job's" + (t/is (thrown? Exception (js/load-input cfg context)))))) + +(t/deftest load-input-refuses-a-missing-or-released-input + (let [cfg (make-cfg) + profile-id (:id (th/create-profile* 1)) + staged (put-artifact cfg profile-id "package-bytes") + context {:id (uuid/next) + :resource-id (:resource-id staged) + :profile-id profile-id}] + + (t/testing "a job without a resource reference has nothing to load" + (t/is (thrown? Exception (js/load-input cfg (dissoc context :resource-id))))) + + (t/testing "an input that was already released cannot be read again" + (t/is (true? (js/release-input cfg context))) + (t/is (thrown? Exception (js/load-input cfg context)))))) + +(t/deftest release-input-is-idempotent-and-never-throws + (let [cfg (make-cfg) + profile-id (:id (th/create-profile* 1)) + staged (put-artifact cfg profile-id "package-bytes") + context {:id (uuid/next) + :resource-id (:resource-id staged) + :profile-id profile-id}] + + (t/is (true? (js/release-input cfg context))) + (t/is (some? (:deleted-at (get-object-row (:resource-id staged))))) + + (t/testing "releasing twice is a no-op, not an error" + (t/is (false? (js/release-input cfg context)))) + + (t/testing "releasing a job without a resource is a no-op too" + (t/is (nil? (js/release-input cfg (dissoc context :resource-id))))))) + +(t/deftest release-input-clears-the-reference-of-the-job + ;; a job that no longer owns its package must not keep pointing at a + ;; deleted object: the object and the pointer are dropped together + (let [cfg (make-cfg) + profile-id (:id (th/create-profile* 1)) + staged (put-artifact cfg profile-id "package-bytes") + job-id (uuid/next) + context {:id job-id + :resource-id (:resource-id staged) + :profile-id profile-id}] + + (th/db-insert! :job {:id job-id + :name "import-binfile" + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :resource-id (:resource-id staged) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (t/is (true? (js/release-input cfg context))) + + (t/testing "the object is marked as deleted" + (t/is (some? (:deleted-at (get-object-row (:resource-id staged)))))) + + (t/testing "and the job row no longer points at it" + (t/is (nil? (:resource-id (th/db-get :job {:id job-id}))))) + + (t/testing "releasing twice leaves the row alone" + (t/is (false? (js/release-input cfg context))) + (t/is (nil? (:resource-id (th/db-get :job {:id job-id}))))))) + +(t/deftest release-resource-releases-a-package-no-job-owns + ;; the creation path stages the package before the job exists: when the + ;; submit fails the package is released with no job id, so the object + ;; goes and no row may be touched by it + (let [cfg (make-cfg) + profile-id (:id (th/create-profile* 1)) + staged (put-artifact cfg profile-id "package-bytes") + job-id (uuid/next)] + (th/db-insert! :job {:id job-id + :name "import-binfile" + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :resource-id nil + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (let [before (th/db-get :job {:id job-id})] + (t/is (true? (js/release-resource cfg (:resource-id staged)))) + + (t/testing "the object is marked as deleted" + (t/is (some? (:deleted-at (get-object-row (:resource-id staged)))))) + + (t/testing "and the unrelated job row is exactly as it was" + (t/is (= before (th/db-get :job {:id job-id}))))))) diff --git a/backend/test/backend_tests/jobs_test.clj b/backend/test/backend_tests/jobs_test.clj index 2aba405738..20675fed2b 100644 --- a/backend/test/backend_tests/jobs_test.clj +++ b/backend/test/backend_tests/jobs_test.clj @@ -27,6 +27,11 @@ (t/use-fixtures :each test-fixture) +(defn- raised + "Run `f` and answer with the exception it raised, or nil when it did not." + [f] + (try (f) nil (catch Throwable cause cause))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; JOB-DEF (plain handler + precompiled init-key) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -39,7 +44,8 @@ [cfg params] (when (::jobs/job-id cfg) (jobs/heartbeat cfg) - (jobs/heartbeat cfg :progress {:current 1 :stage "half"})) + (jobs/heartbeat cfg :progress {:stage :half + :counters {:work {:current 1 :total 2}}})) params) (def schema:echo-params @@ -101,7 +107,24 @@ WHERE job_id = ? AND kind = 'progress' ORDER BY created_at ASC, id ASC" job-id]) - (mapv #(db/decode-json-pgobject (:payload %))))) + (mapv #(jobs/decode-progress (:payload %))))) + +(defn- get-events + "Every event of a job, in insertion order." + [job-id] + (->> (th/db-exec! ["SELECT kind, payload FROM job_event + WHERE job_id = ? ORDER BY id ASC" job-id]) + (mapv (fn [{:keys [kind payload]}] + {:kind kind :payload (db/decode-json-pgobject payload)})))) + +(defn- get-outcomes + "Outcomes of the end events of a job, oldest first." + [job-id] + (mapv (comp :outcome :payload) (filter #(= "end" (:kind %)) (get-events job-id)))) + +(defn- get-kinds + [job-id] + (mapv :kind (get-events job-id))) (defn- fake-msgbus "A minimal msgbus that records every publication on the atom." @@ -159,48 +182,101 @@ (t/testing "no job row was created by the rejected submits" (t/is (= 0 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job"]))))))) +(t/deftest submit-persists-an-expiry-date + (let [cfg (make-cfg (get-job-defs)) + expires-at (ct/plus (ct/now) (ct/duration {:days 7}))] + + (t/testing "an explicit expiry is persisted as the retention of the job" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/expires-at expires-at}) + row (jobs/get-job cfg job-id)] + (t/is (= (inst-ms expires-at) (inst-ms (:expires-at row)))))) + + (t/testing "without the option the column stays nil, as before" + (let [job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)}) + row (jobs/get-job cfg job-id)] + (t/is (nil? (:expires-at row))))))) + +(t/deftest submit-rejects-an-invalid-expiry-date + (let [cfg (make-cfg (get-job-defs))] + (t/testing "a value that is not an instant is rejected before the insert" + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params) + ::jobs/expires-at "not-an-instant"})))) + (t/testing "no job row was created by the rejected submit" + (t/is (= 0 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job"]))))))) + (t/deftest progress-report-validates-its-payload (let [cfg (make-cfg (get-job-defs)) job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params (make-params)})] - (t/testing "current is mandatory" - (t/is (thrown? Exception - (jobs/heartbeat cfg :job-id job-id :progress {:total 10})))) - - (t/testing "unknown keys are rejected" + (t/testing "stage is mandatory" (t/is (thrown? Exception (jobs/heartbeat cfg :job-id job-id - :progress {:current 1 :step 1})))) + :progress {:counters {:work {:current 1}}})))) - (t/testing "current must not be negative" + (t/testing "stage must be a keyword, not a label" + (t/is (thrown? Exception + (jobs/heartbeat cfg :job-id job-id + :progress {:stage "work"})))) + + (t/testing "unknown keys are rejected, at the top and inside a counter" + (t/is (thrown? Exception + (jobs/heartbeat cfg :job-id job-id + :progress {:stage :work :step 1}))) + (t/is (thrown? Exception + (jobs/heartbeat cfg :job-id job-id + :progress {:stage :work + :counters {:work {:current 1 + :step 1}}})))) + + (t/testing "a counter current must not be negative" (t/is (thrown-with-msg? Exception #"negative" (jobs/heartbeat cfg :job-id job-id - :progress {:current -1})))) + :progress {:stage :work + :counters {:work {:current -1}}})))) - (t/testing "total must be positive" + (t/testing "a counter total must be positive" (t/is (thrown-with-msg? Exception #"positive" (jobs/heartbeat cfg :job-id job-id - :progress {:current 0 :total 0})))) + :progress {:stage :work + :counters {:work {:current 0 + :total 0}}})))) - (t/testing "current must not be greater than total" + (t/testing "a counter current must not be greater than its total" (t/is (thrown-with-msg? Exception #"not lower than current" (jobs/heartbeat cfg :job-id job-id - :progress {:current 5 :total 4})))) + :progress {:stage :work + :counters {:work {:current 5 + :total 4}}})))) - (t/testing "stage is limited to 250 characters" - (t/is (thrown-with-msg? Exception #"250" + (t/testing "keys are limited to a name and not a payload" + (t/is (thrown-with-msg? Exception #"64" (jobs/heartbeat cfg :job-id job-id - :progress {:current 1 - :stage (apply str (repeat 251 "x"))}))) + :progress {:stage (keyword (apply str (repeat 65 "x")))})))) + + (t/testing "a milestone without counters is accepted" + (t/is (= 2 (jobs/heartbeat cfg :job-id job-id :progress {:stage :relations})))) + + (t/testing "a milestone with counters is accepted and stored as it is" + (swap! jobs/progresses dissoc job-id) + (swap! jobs/heartbeats dissoc job-id) (t/is (= 2 (jobs/heartbeat cfg :job-id job-id - :progress {:current 1 - :stage (apply str (repeat 250 "x"))}))) - (t/is (= [{:current 1 :stage (apply str (repeat 250 "x"))}] + :progress {:stage :pages + :counters {:files {:current 2 :total 5} + :pages {:current 3 :total 8}}}))) + (t/is (= [{:stage :relations} + {:stage :pages + :counters {:files {:current 2 :total 5} + :pages {:current 3 :total 8}}}] (get-progresss job-id)))) (t/testing "a rejected report never reaches the durable log" - (t/is (= 1 (count (get-progresss job-id))))))) + (t/is (= 2 (count (get-progresss job-id))))))) (t/deftest progress-event-publishes-msgbus-for-profile-jobs (let [cfg (make-cfg (get-job-defs)) @@ -211,7 +287,8 @@ ::jobs/params (make-params) ::jobs/profile-id profile-id})] (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id - :progress {:current 3 :total 7}))) + :progress {:stage :pages + :counters {:files {:current 3 :total 7}}}))) (t/testing "the event is published on the topic of the job profile" (t/is (= 1 (count @messages))) (let [{:keys [topic message]} (first @messages)] @@ -220,7 +297,9 @@ (t/is (= job-id (:job-id message))) (t/is (= profile-id (:profile-id message))) (t/is (= "progress" (:kind message))) - (t/is (= {:current 3 :total 7} (:payload message))) + (t/is (= {:stage :pages + :counters {:files {:current 3 :total 7}}} + (:payload message))) (t/is (some? (:event-id message))) (t/is (some? (:created-at message))))))) @@ -230,7 +309,7 @@ job-cfg (assoc cfg ::mbus/msgbus (fake-msgbus messages)) job-id (jobs/submit job-cfg {::jobs/name :echo ::jobs/params (make-params)})] - (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id :progress {:current 1}))) + (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id :progress {:stage :work}))) (t/testing "the event is stored but nothing is published" (t/is (= 1 (count (get-progresss job-id)))) (t/is (= [] @messages))))) @@ -245,7 +324,7 @@ (t/is (thrown-with-msg? Exception #"require ::mbus/msgbus" (jobs/heartbeat cfg :job-id job-id - :progress {:current 1} + :progress {:stage :work} ::jobs/force? true))) (t/is (= [] (get-progresss job-id)) "the event must not be stored without its notification")) @@ -254,7 +333,7 @@ (th/db-update! :job {:modified-at (ct/in-past {:minutes 5})} {:id job-id}) (swap! jobs/heartbeats dissoc job-id) (t/is (= 1 (jobs/heartbeat cfg :job-id job-id - :progress {:current 1}))) + :progress {:stage :work}))) (t/is (= [] (get-progresss job-id)))))) (t/deftest submit-validates-params-with-job-schema @@ -530,34 +609,67 @@ (t/is (= (inst-ms (:modified-at row1)) (inst-ms (:modified-at row2)))))))) -(t/deftest heartbeat-skips-terminal-states +(t/deftest heartbeat-interrupts-on-terminal-states (let [cfg (make-cfg (get-job-defs)) job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params (make-params)})] - (t/testing "terminal states are never touched by heartbeat" + (t/testing "a terminal state raises the interrupt instead of touching the row" (th/db-update! :job {:status "completed" :modified-at (ct/in-past {:days 10})} {:id job-id}) (swap! @#'jobs/heartbeats dissoc job-id) - (let [before (jobs/get-job cfg job-id)] - (jobs/heartbeat cfg :job-id job-id) + (let [before (jobs/get-job cfg job-id) + cause (raised #(jobs/heartbeat cfg :job-id job-id))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (= :job-interrupted (th/ex-code cause))) + (t/is (= job-id (:job-id (ex-data cause)))) + (t/is (= "completed" (:status (ex-data cause)))) (t/is (= (inst-ms (:modified-at before)) (inst-ms (:modified-at (jobs/get-job cfg job-id))))))))) -(t/deftest progress-events-respect-throttle-and-skip-terminal-states +(t/deftest heartbeat-interrupts-when-the-row-is-gone + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (th/db-force-delete :job {:id job-id}) + (let [cause (raised #(jobs/heartbeat cfg :job-id job-id))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (= :job-interrupted (th/ex-code cause))) + (t/is (= "gone" (:status (ex-data cause))))))) + +(t/deftest a-transient-progress-failure-is-not-an-interrupt + "The handler path logs and ignores a progress insert that fails, but an + interrupt must not be swallowed with it: only an interrupt means the job + is gone." + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (swap! jobs/progresses dissoc job-id) + (swap! jobs/heartbeats dissoc job-id) + ;; the insert is the boundary that fails: the job itself is active, so + ;; the beat answers with the touch it did write instead of raising + (with-redefs-fn {#'jobs/report-progress (fn [& _] (throw (ex-info "boom" {})))} + #(t/is (= 1 (jobs/heartbeat cfg :job-id job-id :progress {:stage :work})))) + (t/is (= [] (get-progresss job-id))))) + +(t/deftest progress-events-respect-throttle-and-interrupt-on-terminal-states (let [cfg (make-cfg (get-job-defs)) job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params (make-params)})] (t/testing "first progress report appends a progress event" (t/is (= 2 (jobs/heartbeat cfg :job-id job-id - :progress {:current 1 :total 10}))) - (t/is (= [{:current 1 :total 10}] (get-progresss job-id)))) + :progress {:stage :pages + :counters {:pages {:current 1 :total 10}}}))) + (t/is (= [{:stage :pages :counters {:pages {:current 1 :total 10}}}] + (get-progresss job-id)))) (t/testing "immediate second progress report is throttled" (t/is (= 0 (jobs/heartbeat cfg :job-id job-id - :progress {:current 2 :total 10}))) - (t/is (= [{:current 1 :total 10}] (get-progresss job-id))) + :progress {:stage :pages + :counters {:pages {:current 2 :total 10}}}))) + (t/is (= [{:stage :pages :counters {:pages {:current 1 :total 10}}}] + (get-progresss job-id))) (t/testing "after the throttle window elapses it appends again" (swap! @#'jobs/progresses @@ -565,9 +677,11 @@ (update-in m [job-id] #(ct/minus % (ct/duration {:seconds 2}))))) - (jobs/heartbeat cfg :job-id job-id :progress {:current 3 :total 10}) - (t/is (= [{:current 1 :total 10} - {:current 3 :total 10}] + (jobs/heartbeat cfg :job-id job-id + :progress {:stage :pages + :counters {:pages {:current 3 :total 10}}}) + (t/is (= [{:stage :pages :counters {:pages {:current 1 :total 10}}} + {:stage :pages :counters {:pages {:current 3 :total 10}}}] (get-progresss job-id)))))) (let [cfg (make-cfg (get-job-defs)) @@ -576,9 +690,11 @@ (t/testing "terminal states never get a progress event" (th/db-update! :job {:status "completed"} {:id job-id}) (swap! @#'jobs/progresses dissoc job-id) - (t/is (= 0 (jobs/heartbeat cfg :job-id job-id - :progress {:current 9}))) - (t/is (= [] (get-progresss job-id)))))) + (swap! @#'jobs/heartbeats dissoc job-id) + (let [cause (raised #(jobs/heartbeat cfg :job-id job-id + :progress {:stage :work}))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (= [] (get-progresss job-id))))))) (t/deftest throttle-prune-removes-stale-entries-keeps-fresh "When the throttle map exceeds prune-threshold, stale entries (older than @@ -609,7 +725,7 @@ (t/is (contains? state job-id-2) "fresh entry kept") (t/is (contains? state job-id-3) "new entry added")))))) -(t/deftest cancel-skips-running-and-terminal-jobs +(t/deftest cancel-skips-terminal-jobs (let [cfg (make-cfg (get-job-defs)) job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params (make-params)})] @@ -617,27 +733,30 @@ (t/testing "pending job can be cancelled" (t/is (= 1 (jobs/cancel cfg job-id))) (t/is (= "cancelled" (:status (jobs/get-job cfg job-id)))) - + (t/is (= ["cancelled"] (get-outcomes job-id))) (t/testing "already cancelled job is not affected again" - (t/is (zero? (jobs/cancel cfg job-id)))))) + (t/is (zero? (jobs/cancel cfg job-id))) + (t/is (= ["cancelled"] (get-outcomes job-id)))))) + (doseq [status ["completed" "failed"]] + (t/testing (str status " job cannot be cancelled") + (let [cfg (make-cfg (get-job-defs)) + job-id (jobs/submit cfg {::jobs/name :echo + ::jobs/params (make-params)})] + (th/db-update! :job {:status status} {:id job-id}) + (t/is (zero? (jobs/cancel cfg job-id))) + (t/is (= status (:status (jobs/get-job cfg job-id)))) + (t/is (= [] (get-outcomes job-id))))))) - (let [cfg (make-cfg (get-job-defs)) - job-id (jobs/submit cfg {::jobs/name :echo - ::jobs/params (make-params)})] - (t/testing "running job cannot be cancelled" - (th/db-update! :job {:status "running"} {:id job-id}) - (t/is (zero? (jobs/cancel cfg job-id))) - (t/is (= "running" (:status (jobs/get-job cfg job-id))))))) - -(t/deftest cancel-affects-scheduled-and-retry-jobs +(t/deftest cancel-affects-pending-and-running-jobs (let [cfg (make-cfg (get-job-defs))] - (doseq [status ["scheduled" "retry"]] + (doseq [status ["scheduled" "retry" "running"]] (t/testing (str status " job can be cancelled") (let [job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params (make-params)})] (th/db-update! :job {:status status} {:id job-id}) (t/is (= 1 (jobs/cancel cfg job-id))) - (t/is (= "cancelled" (:status (jobs/get-job cfg job-id))))))))) + (t/is (= "cancelled" (:status (jobs/get-job cfg job-id)))) + (t/is (= ["cancelled"] (get-outcomes job-id)))))))) (t/deftest get-user-status-maps-internal-statuses (t/are [status expected] (= expected (jobs/get-user-status status)) @@ -673,6 +792,32 @@ ::jobs/handler (fn [_context params] params)}}}))))) +(t/deftest job-def-carries-optional-family-and-resource-role + (let [job-def {::jobs/name :echo + ::jobs/family :export + ::jobs/resource-role :output + ::jobs/schema schema:echo-params + ::jobs/handler (fn [_context params] params) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params)}] + + (t/testing "the metadata is accepted and readable from the registry" + (let [defs (-> (ig/init {::jobs/defs {:echo job-def}}) + (get ::jobs/defs)) + echo (jobs/get-job-def defs :echo)] + (t/is (= :export (::jobs/family echo))) + (t/is (= :output (::jobs/resource-role echo))))) + + (t/testing "a family is a keyword, not a free string" + (t/is (thrown? Exception + (ig/init {::jobs/defs {:echo (assoc job-def + ::jobs/family "export")}})))) + + (t/testing "a resource role is a keyword too" + (t/is (thrown? Exception + (ig/init {::jobs/defs {:echo (assoc job-def + ::jobs/resource-role "output")}})))))) + (t/deftest generic-schema-round-trip-preserves-type-sensitive-fields "For each registered job-def, verify that type-sensitive fields (uuids, insts) survive the JSON round-trip (db/json → decode). This catches the F2 class of @@ -742,25 +887,28 @@ (t/testing "progress reporting is a no-op when *job-id* is nil" (binding [jobs/*job-id* nil] - (t/is (nil? (jobs/heartbeat cfg :progress {:current 1})))) + (t/is (nil? (jobs/heartbeat cfg :progress {:stage :work})))) (t/is (= [] (get-progresss job-id)) "no progress event should be stored")))) -(t/deftest progress-noop-on-terminal-states +(t/deftest progress-interrupts-on-terminal-states "A progress report never stores an event when the job is already in a terminal state (completed, failed, cancelled, aborted): the row is locked and checked before the insert, so a report can never race a terminal - transition." + transition, and the beat raises the interrupt instead." (let [cfg (make-cfg (get-job-defs))] (doseq [status ["completed" "failed" "cancelled" "aborted"]] - (t/testing (str "progress is a no-op on " status " status") + (t/testing (str "progress interrupts on " status " status") (let [job-id (jobs/submit cfg {::jobs/name :echo ::jobs/params (make-params)})] (th/db-update! :job {:status status} {:id job-id}) - ;; reset the throttle so should-write? would allow the write + ;; reset the throttles so should-write? would allow the writes (swap! jobs/progresses dissoc job-id) + (swap! jobs/heartbeats dissoc job-id) (binding [jobs/*job-id* job-id] - (t/is (= 0 (jobs/heartbeat cfg :progress {:current 1})))) + (let [cause (raised #(jobs/heartbeat cfg :progress {:stage :work}))] + (t/is (= :interrupt (th/ex-type cause))) + (t/is (= status (:status (ex-data cause)))))) (t/is (= [] (get-progresss job-id)))))))) (t/deftest defs-halt-clears-module-registry @@ -797,8 +945,8 @@ (fn [{:keys [::db/conn]}] (jobs/heartbeat (assoc cfg ::db/conn conn) :job-id job-id - :progress {:current 1}))) - (t/is (= [{:current 1}] (get-progresss job-id))))) + :progress {:stage :work}))) + (t/is (= [{:stage :work}] (get-progresss job-id))))) (t/deftest progress-notification-survives-a-rolled-back-caller (let [cfg (make-cfg (get-job-defs)) @@ -814,9 +962,9 @@ (fn [{:keys [::db/conn]}] (jobs/heartbeat (assoc job-cfg ::db/conn conn) :job-id job-id - :progress {:current 1}))) + :progress {:stage :work}))) (t/testing "the event is stored" - (t/is (= [{:current 1}] (get-progresss job-id)))) + (t/is (= [{:stage :work}] (get-progresss job-id)))) (t/testing "and the notification went out with its own commit" (t/is (= 1 (count @messages))) (t/is (= "progress" (:kind (:message (first @messages)))))))) @@ -829,7 +977,7 @@ (t/is (thrown? Exception (jobs/heartbeat th/*pool* :job-id job-id - :progress {:current 1})))))) + :progress {:stage :work})))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -844,6 +992,7 @@ :tenant (cf/get :tenant) :queue "default" :label nil + :profile-id nil :resource-id nil :retry-num 0 :max-retries 3} @@ -851,7 +1000,7 @@ (t/deftest make-context-selects-exactly-the-agreed-keys (let [context (jobs/make-context (mk-row {}))] - (t/is (= #{:id :name :label :resource-id} (set (keys context)))) + (t/is (= #{:id :name :label :resource-id :profile-id} (set (keys context)))) (t/testing "the queue is a routing detail of the dispatcher, not context" (t/is (not (contains? context :queue)))) (t/testing "nor is the tenant that owns the queue" @@ -880,11 +1029,23 @@ (t/is (= resource-id (:resource-id (jobs/make-context (mk-row {:resource-id resource-id})))))))) +(t/deftest make-context-carries-the-job-owner + (t/testing "the key exists even when the job has no owner" + (let [context (jobs/make-context (mk-row {}))] + (t/is (contains? context :profile-id)) + (t/is (nil? (:profile-id context))))) + (t/testing "the owner of a user job is carried as is" + (let [profile-id (uuid/next)] + (t/is (= profile-id + (:profile-id (jobs/make-context (mk-row {:profile-id profile-id})))))))) + (t/deftest make-context-rejects-invalid-rows (t/testing "a missing id is rejected" (t/is (thrown? Exception (jobs/make-context (mk-row {:id nil}))))) (t/testing "a missing name is rejected" (t/is (thrown? Exception (jobs/make-context (mk-row {:name nil}))))) + (t/testing "a non uuid owner is rejected" + (t/is (thrown? Exception (jobs/make-context (mk-row {:profile-id "nope"}))))) (t/testing "a non uuid resource reference is rejected" (t/is (thrown? Exception (jobs/make-context (mk-row {:resource-id "nope"})))))) @@ -955,29 +1116,13 @@ "a job-id without a context still delivers a nil context") (t/testing "the job-id is what makes the durable writes reach the row" (t/is (pos? (jobs/heartbeat cfg :job-id job-id - :progress {:current 1}))) - (t/is (= [{:current 1}] (get-progresss job-id)))))) + :progress {:stage :work}))) + (t/is (= [{:stage :work}] (get-progresss job-id)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; LIFECYCLE EVENTS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- get-events - "Every event of a job, in insertion order." - [job-id] - (->> (th/db-exec! ["SELECT kind, payload FROM job_event - WHERE job_id = ? ORDER BY id ASC" job-id]) - (mapv (fn [{:keys [kind payload]}] - {:kind kind :payload (db/decode-json-pgobject payload)})))) - -(defn- get-kinds - [job-id] - (mapv :kind (get-events job-id))) - -(defn- get-outcomes - [job-id] - (mapv (comp :outcome :payload) (filter #(= "end" (:kind %)) (get-events job-id)))) - (defn- mk-running "A job already claimed by a worker, ready for a terminal transition." [cfg] @@ -1269,6 +1414,23 @@ (t/is (nil? (:resource-id row))) (t/is (= "running" (:status row)))))))) +(t/deftest insert-event-requires-a-transaction + ;; The event insert is only valid inside the transaction that owns the + ;; job row: outside one the assert fires instead of writing. Like every + ;; assert-based guard, this only holds with the backend asserts flag. + (t/is (true? *assert*) + "run the suite with PENPOT_FLAGS=\"enable-backend-asserts\"") + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg)] + (t/testing "a pool cfg with no transaction raises" + (t/is (thrown? AssertionError + (@#'jobs/insert-event cfg job-id "progress" {:stage :half})))) + (t/testing "a bare connection with no transaction raises too" + (db/run! cfg (fn [conn-cfg] + (t/is (thrown? AssertionError + (@#'jobs/insert-event conn-cfg job-id "progress" + {:stage :half})))))))) + (t/deftest complete-associates-a-resource-id-once (let [cfg (make-cfg (get-job-defs)) resource-id (uuid/next)] @@ -1393,10 +1555,17 @@ (let [cfg (make-cfg (get-job-defs)) job-id (mk-running cfg)] (t/testing "a progress report and a completion of the same job, at once" - (let [[_writes completed] (race #(jobs/heartbeat cfg :job-id job-id - :progress {:current 1} - ::jobs/force? true) - #(jobs/complete cfg :job-id job-id :result {:v 1}))] + ;; the report either lands before the end or loses the race and gets + ;; the interrupt: a beat on a job that is no longer active raises + (let [report (fn [] + (if (some? (raised #(jobs/heartbeat cfg :job-id job-id + :progress {:stage :work} + ::jobs/force? true))) + :interrupted + :reported)) + [outcome completed] (race report #(jobs/complete cfg :job-id job-id :result {:v 1}))] + (t/is (contains? #{:interrupted :reported} outcome) + "the report lands or gets the interrupt, never anything else") (t/is (= 1 completed) "the completion always wins, it owns the row") (t/testing "the report either lost the race or was stored before the end" (let [kinds (get-kinds job-id)] @@ -1406,10 +1575,11 @@ (t/is (<= 2 (count kinds) 3) "at most the one report that made it in before the end")))) (t/testing "a job that ended is not resurrected by a late report" - (let [before (get-kinds job-id)] - (t/is (zero? (jobs/heartbeat cfg :job-id job-id - :progress {:current 2} - ::jobs/force? true))) + (let [before (get-kinds job-id) + cause (raised #(jobs/heartbeat cfg :job-id job-id + :progress {:stage :work} + ::jobs/force? true))] + (t/is (= :interrupt (th/ex-type cause))) (t/is (= before (get-kinds job-id)) "the late report stored nothing")))))) (t/deftest progress-event-reaches-the-cfg-of-its-own-callback @@ -1426,9 +1596,9 @@ ::jobs/params (make-params) ::jobs/profile-id profile-id})] (t/is (pos? (jobs/heartbeat job-cfg :job-id job-id - :progress {:current 1}))) + :progress {:stage :work}))) (t/testing "the event was stored" - (t/is (= [{:current 1}] (get-progresss job-id)))) + (t/is (= [{:stage :work}] (get-progresss job-id)))) (t/testing "and the msgbus on that cfg was reachable from the callback" (t/is (= 1 (count @messages))) (t/is (= "progress" (:kind (:message (first @messages)))))))) diff --git a/backend/test/backend_tests/rpc/commands/jobs_test.clj b/backend/test/backend_tests/rpc/commands/jobs_test.clj new file mode 100644 index 0000000000..a03b5a9ca4 --- /dev/null +++ b/backend/test/backend_tests/rpc/commands/jobs_test.clj @@ -0,0 +1,717 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc.commands.jobs-test + "The creation of a user-facing job: what it answers, what it freezes and + what it refuses before anything is stored." + (:require + [app.binfile.common :as bfc] + [app.binfile.v3 :as v3] + [app.common.exceptions :as ex] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.loggers.audit :as-alias audit] + [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] + [app.rpc.commands.jobs :as cmd-jobs] + [app.rpc.quotes :as-alias quotes] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [backend-tests.helpers :as th] + [clojure.java.io :as jio] + [clojure.test :as t] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.util.zip.ZipEntry + java.util.zip.ZipFile + java.util.zip.ZipOutputStream)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) + +(def ^:private fixture + "A real penpot export, the same fixture the binfile tests read." + "backend_tests/test_files/svg-attrs-camel-case.penpot") + +(defn- import-fixture! + "A file in the default project of the profile, imported from the + fixture through the core." + [profile] + (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input (-> fixture io/resource jio/file)) + (v3/import-files!))) + +(defn- create-export-binfile-job + [profile-id file-ids & {:as extra}] + (th/command! (merge {::th/type :create-export-binfile-job + ::rpc/profile-id profile-id + :params {:file-ids file-ids + :export-type :detach-libraries}} + extra))) + +(defn- count-jobs + [] + (count (th/db-exec! ["SELECT id FROM job"]))) + +(defn- count-storage-objects + [] + (count (th/db-exec! ["SELECT id FROM storage_object"]))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest create-export-binfile-job-answers-with-a-pending-job + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + before (count-storage-objects) + out (create-export-binfile-job (:id profile) #{file-id})] + + (t/is (th/success? out)) + + (let [result (:result out) + job (jobs/get-job th/*system* (:id result))] + + (t/testing "the answer is enough to follow the job" + (t/is (uuid? (:id result))) + (t/is (= "pending" (:status result))) + (t/is (= "export-binfile" (:name result))) + (t/is (ct/inst? (:created-at result))) + (t/is (pos? (- (inst-ms (:expires-at result)) (inst-ms (ct/now)))))) + + (t/testing "and the row freezes what the job will do" + (let [job-def (jobs/get-job-def (::jobs/defs th/*system*) :export-binfile) + params (jobs/decode-params job-def (:params job))] + (t/is (= "new" (:status job))) + (t/is (= "binfile" (:queue job))) + (t/is (= 0 (:max-retries job))) + (t/is (= (:id profile) (:profile-id job))) + (t/is (= #{file-id} (:file-ids params))) + (t/is (= :detach-libraries (:export-type params))))) + + (t/testing "creating a job does not run it" + ;; no runner is running here, so a call that waited for one would + ;; hang instead of answering: seeing the job pending and no + ;; artifact produced is what proves the call does not wait + (t/is (= before (count-storage-objects))))))) + +(t/deftest create-export-binfile-job-requires-the-export-type + ;; the type is part of the contract of the job: a caller that does not + ;; say how to handle the libraries gets an error, not a package it did + ;; not ask for + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + out (th/command! {::th/type :create-export-binfile-job + ::rpc/profile-id (:id profile) + :params {:file-ids #{file-id}}})] + + (t/is (not (th/success? out))) + (t/is (= :params-validation (th/ex-code (:error out)))) + (t/testing "and nothing was created" + (t/is (zero? (count-jobs)))))) + +(t/deftest create-export-binfile-job-requires-at-least-one-file + (let [profile (th/create-profile* 1) + out (create-export-binfile-job (:id profile) #{})] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :no-files-to-export (th/ex-code (:error out)))) + (t/testing "and nothing was created" + (t/is (zero? (count-jobs)))))) + +(t/deftest create-export-binfile-job-checks-the-read-permission + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + file-id (first (:file-ids (import-fixture! owner))) + out (create-export-binfile-job (:id other) #{file-id})] + + (t/testing "a file the caller cannot read is not exported" + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out))))) + + (t/testing "and no job was created for it" + (t/is (zero? (count-jobs)))))) + +(t/deftest create-export-binfile-job-enforces-the-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-export-jobs-per-profile 1})}] + + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile)))] + + (t/testing "the first job fits in the quote" + (t/is (th/success? (create-export-binfile-job (:id profile) #{file-id}))) + (t/is (= 1 (count-jobs)))) + + (t/testing "the one that would go over it is refused" + (let [out (create-export-binfile-job (:id profile) #{file-id})] + (t/is (not (th/success? out))) + (t/is (= :restriction (th/ex-type (:error out)))) + (t/is (= :max-quote-reached (th/ex-code (:error out)))) + (t/is (= "export-jobs-per-profile" (:target (ex-data (:error out))))))) + + (t/testing "so the quote is what stopped the second job" + (t/is (= 1 (count-jobs))))))) + +(t/deftest create-export-binfile-job-adds-the-job-id-to-the-audit-props + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + out (create-export-binfile-job (:id profile) #{file-id}) + props (::audit/props (meta (:result out)))] + + (t/testing "the audit of the call points at the job, not at its files" + (t/is (= (:id (:result out)) (:job-id props))) + (t/is (= 1 (:files props))) + (t/is (= "detach-libraries" (:export-type props))) + (t/is (= #{:job-id :files :export-type} (set (keys props))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMPORT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- tempfile-with + "A temporary file holding `content`, as the http layer would leave one + for a plain upload." + [content] + (let [path (fs/create-tempfile :dir tmp/default-tmp-dir + :prefix "test-upload-")] + (with-open [out (io/output-stream path)] + (io/write* out (.getBytes ^String content "UTF-8"))) + path)) + +(defn- upload-chunk + "One chunk of the fixture, as the upload command takes it. `content` + replaces the fixture with something that is no package at all." + [& {:keys [content]}] + (if (some? content) + {:filename "package.penpot" + :path (tempfile-with content) + :mtype "application/zip" + :size (count content)} + (let [source (-> fixture io/resource jio/file)] + {:filename "package.penpot" + :path (th/tempfile fixture) + :mtype "application/zip" + :size (.length source)}))) +(defn- upload-chunked! + "Upload one chunk and return the session id. By default the chunk is the + fixture; `:path` uploads another file and `:content` uploads a string + that is no package at all." + [profile & {:keys [content path]}] + (let [session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id profile) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id profile) + :session-id session-id + :index 0 + :content (if (some? path) + (let [size (.length (jio/file path))] + {:filename "package.penpot" + :path path + :mtype "application/zip" + :size size}) + (upload-chunk :content content))})] + (t/is (nil? (:error out))) + session-id)) + +(defn- create-import-binfile-job + [profile-id project-id & {:keys [upload-id] :as extra}] + (th/command! (merge {::th/type :create-import-binfile-job + ::rpc/profile-id profile-id + :params {:project-id project-id + :name "imported"} + :upload-id upload-id} + extra))) + +(defn- session-row + [session-id] + (th/db-get :upload-session {:id session-id} {::db/remove-deleted false})) + +(defn- package-with-refer + "The fixture with the key of its manifest renamed to the old spelling, + `refer`, and keeping the value it already had." + [] + (let [source (-> fixture io/resource jio/file) + target (fs/create-tempfile :dir tmp/default-tmp-dir + :prefix "test-refer-")] + (with-open [zip (ZipFile. source) + out (ZipOutputStream. (io/output-stream target))] + (doseq [entry (enumeration-seq (.entries zip))] + (.putNextEntry out (ZipEntry. (.getName entry))) + (when-not (.isDirectory entry) + (let [content (slurp (.getInputStream zip entry)) + content (if (= "manifest.json" (.getName entry)) + (str/replace content #"\"referer\"(?=\s*:)" "\"refer\"") + content)] + (.write out (.getBytes content "UTF-8")))) + (.closeEntry out))) + target)) + +(defn- manifest-of + "The manifest of a package, as the reader of the import sees it." + [path] + (v3/get-manifest th/*system* path)) + +(defn- live-storage-objects + [] + (count (th/db-exec! ["SELECT id FROM storage_object WHERE deleted_at IS NULL"]))) + +(defn- session-row + [session-id] + (th/db-get :upload-session {:id session-id} {::db/remove-deleted false})) + +(defn- package-with-refer + "The fixture with the key of its manifest renamed to the old spelling, + `refer`, and keeping the value it already had." + [] + (let [source (-> fixture io/resource jio/file) + target (fs/create-tempfile :dir tmp/default-tmp-dir + :prefix "test-refer-")] + (with-open [zip (ZipFile. source) + out (ZipOutputStream. (io/output-stream target))] + (doseq [entry (enumeration-seq (.entries zip))] + (.putNextEntry out (ZipEntry. (.getName entry))) + (when-not (.isDirectory entry) + (let [content (slurp (.getInputStream zip entry)) + content (if (= "manifest.json" (.getName entry)) + (str/replace content #"\"referer\"(?=\s*:)" "\"refer\"") + content)] + (.write out (.getBytes content "UTF-8")))) + (.closeEntry out))) + target)) + +(defn- manifest-of + "The manifest of a package, as the reader of the import sees it." + [path] + (v3/get-manifest th/*system* path)) + +(defn- assembled-tempfiles + "The temporary files the chunk assembly leaves behind. The copies this + test uploads are its own business." + [] + (->> (.list (jio/file tmp/default-tmp-dir)) + (filter #(str/starts-with? % "penpot.chunked-upload.")) + (seq))) + +(t/deftest create-import-binfile-job-audits-the-old-spelling-of-the-manifest-tool + (let [profile (th/create-profile* 1) + package (package-with-refer) + expected (:referer (manifest-of (-> fixture io/resource jio/file))) + session-id (upload-chunked! profile :path package) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :upload-id session-id) + props (::audit/props (meta (:result out)))] + + (t/testing "the package names its tool with the old spelling only" + (t/is (some? expected)) + (t/is (nil? (:referer (manifest-of package)))) + (t/is (= expected (:refer (manifest-of package))))) + + (t/testing "and the audit carries that value anyway" + (t/is (th/success? out)) + (t/is (= expected (:referer props)))))) + +(t/deftest create-import-binfile-job-limits-its-own-concurrency + ;; the command assembles and stores the package inside the request, so + ;; it declares the same kind of limit the legacy import command has + (let [[mdata _] (get (::rpc/methods th/*system*) :create-import-binfile-job)] + (t/is (= [[:create-import-binfile-job/by-profile ::rpc/profile-id] + [:create-import-binfile-job/global]] + (::climit/id mdata))))) + +(t/deftest create-export-binfile-job-accepts-the-params-of-a-json-client + ;; a JSON body carries text and lists, not uuids or sets: the command + ;; has to read them with the decoder of the job-def, like the runner + ;; does when it reads a job from its row + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + out (th/command! {::th/type :create-export-binfile-job + ::rpc/profile-id (:id profile) + :params {:file-ids [(str file-id)] + :export-type "detach-libraries"}})] + + (t/is (th/success? out)) + + (let [job-def (jobs/get-job-def (::jobs/defs th/*system*) :export-binfile) + params (jobs/decode-params job-def + (:params (jobs/get-job th/*system* + (:id (:result out)))))] + (t/is (= #{file-id} (:file-ids params))) + (t/is (= :detach-libraries (:export-type params)))))) + +(t/deftest create-import-binfile-job-accepts-the-params-of-a-json-client + (let [profile (th/create-profile* 1) + session-id (upload-chunked! profile) + out (th/command! {::th/type :create-import-binfile-job + ::rpc/profile-id (:id profile) + :params {:project-id (str (:default-project-id profile)) + :name "imported"} + :upload-id session-id})] + + (t/is (th/success? out)))) + +(t/deftest create-import-binfile-job-never-takes-a-path-from-the-caller + (let [profile (th/create-profile* 1) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :file {:filename "package.penpot" + :path (jio/file "/etc/passwd") + :mtype "application/zip" + :size 0})] + + (t/testing "a plain upload is not a way to name a package" + (t/is (not (th/success? out))) + (t/is (= :params-validation (th/ex-code (:error out))))) + + (t/testing "and no job is created" + (t/is (zero? (count-jobs)))))) + +(t/deftest create-import-binfile-job-answers-with-a-pending-job + (let [profile (th/create-profile* 1) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :upload-id (upload-chunked! profile))] + + (t/is (th/success? out)) + + (let [result (:result out) + job (jobs/get-job th/*system* (:id result)) + staged (sto/get-object (:app.storage/storage th/*system*) (:resource-id job))] + + (t/testing "the answer is enough to follow the job" + (t/is (uuid? (:id result))) + (t/is (= "pending" (:status result))) + (t/is (= "import-binfile" (:name result)))) + + (t/testing "the row points at the package the job will consume" + (t/is (some? (:resource-id job))) + (t/is (= "binfile" (:queue job))) + (t/is (= sto/job-resource-bucket (:bucket (meta staged)))) + (t/is (= (:id profile) (:profile-id (meta staged))))) + + (t/testing "and freezes the destination and the manifest metadata" + (let [job-def (jobs/get-job-def (::jobs/defs th/*system*) :import-binfile) + params (jobs/decode-params job-def (:params job))] + (t/is (= (:default-project-id profile) (:project-id params))) + (t/is (= "imported" (:name params))) + (t/is (= 3 (:version params))) + (t/is (string? (:generated-by params))))) + + (t/testing "the audit points at the job and the manifest, not the package" + (let [props (::audit/props (meta (:result out)))] + (t/is (= (:id result) (:job-id props))) + (t/is (= #{:job-id :generated-by :referer} (set (keys props))))))))) + +(t/deftest create-import-binfile-job-assembles-the-chunks-of-its-upload + (let [profile (th/create-profile* 1) + session-id (upload-chunked! profile) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :upload-id session-id)] + + (t/is (th/success? out)) + + (t/testing "the assembled package is the resource of the job" + (let [job (jobs/get-job th/*system* (:id (:result out)))] + (t/is (some? (:resource-id job))))) + + (t/testing "the upload session is consumed" + (t/is (some? (:deleted-at (session-row session-id))))) + + (t/testing "and the temporary file of the assembly is gone" + (t/is (empty? (assembled-tempfiles)))))) + +(t/deftest create-import-binfile-job-rejects-an-incomplete-upload + (let [profile (th/create-profile* 1) + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id profile) + :total-chunks 2}) + :result :session-id) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :upload-id session-id)] + + (t/is (not (th/success? out))) + (t/is (= :missing-chunks (th/ex-code (:error out)))) + (t/is (zero? (count-jobs))))) + +(t/deftest create-import-binfile-job-refuses-an-upload-of-another-profile + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + session-id (upload-chunked! owner) + out (create-import-binfile-job (:id other) (:default-project-id other) + :upload-id session-id)] + + (t/testing "the upload belongs to the profile that made it" + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out))))) + + (t/testing "and it is not consumed by the attempt" + (t/is (nil? (:deleted-at (session-row session-id))))) + + (t/testing "nor is any job created" + (t/is (zero? (count-jobs)))))) + +(t/deftest create-import-binfile-job-checks-the-edition-permission + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + session-id (upload-chunked! other) + out (create-import-binfile-job (:id other) (:default-project-id owner) + :upload-id session-id)] + + (t/testing "a job that cannot edit the destination is refused" + (t/is (not (th/success? out)))) + + (t/testing "before the upload is assembled" + (t/is (nil? (:deleted-at (session-row session-id))))) + + (t/testing "and no job is created" + (t/is (zero? (count-jobs)))))) + +(t/deftest create-import-binfile-job-refuses-a-version-that-does-not-exist + (let [profile (th/create-profile* 1) + session-id (upload-chunked! profile) + ;; the chunks of the upload are alive: the GC reclaims them later + before (live-storage-objects) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :params {:project-id (:default-project-id profile) + :name "imported" + :version 2} + :upload-id session-id)] + + (t/is (not (th/success? out))) + (t/is (= :params-validation (th/ex-code (:error out)))) + (t/is (zero? (count-jobs))) + + (t/testing "no package was staged for it" + (t/is (= before (live-storage-objects)))) + + (t/testing "and the upload is not consumed by the rejection" + (t/is (nil? (:deleted-at (session-row session-id))))) + + (t/testing "so nothing had to be assembled either" + (t/is (empty? (assembled-tempfiles)))))) + +(t/deftest create-import-binfile-job-rejects-malformed-params-before-assembling + ;; the shape of the caller params is checked before the chunks are + ;; assembled: a name over the limit never copies the package to disk + (let [profile (th/create-profile* 1) + session-id (upload-chunked! profile) + before (live-storage-objects) + out (with-redefs [cmd-jobs/assemble-upload + (fn [& _] + (throw (ex-info "assembled" {})))] + (create-import-binfile-job (:id profile) (:default-project-id profile) + :params {:project-id (:default-project-id profile) + :name (apply str (repeat 251 "x"))} + :upload-id session-id))] + + (t/is (not (th/success? out))) + (t/is (= :params-validation (th/ex-code (:error out)))) + (t/is (zero? (count-jobs))) + + (t/testing "no package was staged for it" + (t/is (= before (live-storage-objects)))) + + (t/testing "so nothing had to be assembled either" + (t/is (empty? (assembled-tempfiles)))))) + +(t/deftest create-import-binfile-job-leaves-no-temporary-when-the-package-is-corrupt + (let [profile (th/create-profile* 1) + ;; a header that says "version 3" (the zip magic) over bytes that + ;; are not a zip at all: what fails is reading the manifest, once + ;; the chunks have already been assembled + session-id (upload-chunked! profile + {:content (str "PK\u0003\u0004" "not a zip")}) + before (live-storage-objects) + out (create-import-binfile-job (:id profile) (:default-project-id profile) + :upload-id session-id)] + + (t/is (not (th/success? out))) + (t/is (zero? (count-jobs))) + + (t/testing "no package was staged" + (t/is (= before (live-storage-objects)))) + + (t/testing "and the file assembled from the chunks is gone" + (t/is (empty? (assembled-tempfiles)))))) + +(t/deftest create-import-binfile-job-releases-the-package-when-the-submit-fails + (let [profile (th/create-profile* 1) + session-id (upload-chunked! profile) + ;; the chunks of the upload are alive: the GC reclaims them later + before (live-storage-objects) + out (with-redefs [jobs/submit (fn [& _] + (ex/raise :type :internal + :code :boom + :hint "cannot create the job"))] + (create-import-binfile-job (:id profile) (:default-project-id profile) + :upload-id session-id))] + + (t/testing "the failure reaches the caller" + (t/is (not (th/success? out)))) + + (t/testing "the staged package does not stay alive" + (t/is (= before (live-storage-objects)))) + + (t/testing "and no job row was left behind" + (t/is (zero? (count-jobs)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; READ +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- get-job! + [profile-id job-id] + (th/command! {::th/type :get-job + ::rpc/profile-id profile-id + :id job-id})) + +(defn- claim-job! + "Drive a pending job to `running`, as the dispatcher would: the terminal + writers only accept a job that is being executed." + [job-id] + (jobs/claim th/*system* job-id + (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at)))) + +(t/deftest get-job-answers-with-the-state-of-the-job + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (:id (:result (create-export-binfile-job (:id profile) #{file-id}))) + out (get-job! (:id profile) job-id)] + + (t/is (th/success? out)) + (let [result (:result out)] + (t/is (= job-id (:id result))) + (t/is (= "pending" (:status result))) + (t/is (= "export-binfile" (:name result))) + (t/is (ct/inst? (:created-at result))) + (t/is (ct/inst? (:expires-at result))) + + (t/testing "a job that has not finished has nothing to report yet" + (t/is (not (contains? result :result))) + (t/is (not (contains? result :error))))))) + +(t/deftest get-job-answers-with-the-result-of-a-finished-job + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (:id (:result (create-export-binfile-job (:id profile) #{file-id})))] + + (t/is (pos? (claim-job! job-id))) + (t/is (pos? (jobs/complete th/*system* :job-id job-id + :result {:value 42 :file-ids [file-id]}))) + + (let [result (:result (get-job! (:id profile) job-id))] + (t/is (= "completed" (:status result))) + (t/testing "the result comes back as the map the handler produced" + ;; the row holds plain json, so a uuid comes back as the text it is + (t/is (= {:value 42 :file-ids [(str file-id)]} (:result result)))) + (t/is (not (contains? result :error)))))) + +(t/deftest get-job-answers-with-the-error-of-a-failed-job + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (:id (:result (create-export-binfile-job (:id profile) #{file-id})))] + + (t/is (pos? (claim-job! job-id))) + (t/is (pos? (jobs/fail th/*system* job-id {:type :internal + :code :boom + :hint "boom"}))) + + (let [result (:result (get-job! (:id profile) job-id))] + (t/is (= "failed" (:status result))) + (t/testing "the error is the public summary, with its keywords back" + (t/is (= {:type :internal :code :boom :hint "boom"} (:error result)))) + (t/testing "and it carries no result" + (t/is (not (contains? result :result))))))) + +(t/deftest get-job-hides-a-job-of-another-profile + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + file-id (first (:file-ids (import-fixture! owner))) + job-id (:id (:result (create-export-binfile-job (:id owner) #{file-id})))] + + (t/testing "the owner reads it" + (t/is (th/success? (get-job! (:id owner) job-id)))) + + (t/testing "another profile gets the answer of a job that does not exist" + (let [out (get-job! (:id other) job-id)] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + + (t/testing "and so does an unknown id" + (let [out (get-job! (:id owner) (uuid/next))] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; CANCEL +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- cancel-job + [profile-id job-id] + (th/command! {::th/type :cancel-job + ::rpc/profile-id profile-id + :id job-id})) + +(t/deftest cancel-job-cancels-a-running-job + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (:id (:result (create-export-binfile-job (:id profile) #{file-id})))] + + (t/is (pos? (claim-job! job-id))) + + (let [out (cancel-job (:id profile) job-id)] + (t/is (th/success? out)) + (t/testing "the answer names the cancelled job" + (t/is (= job-id (:id (:result out)))) + (t/is (= "cancelled" (:status (:result out))))) + (t/testing "and the row is cancelled" + (t/is (= "cancelled" (:status (jobs/get-job th/*system* job-id)))))))) + +(t/deftest cancel-job-hides-a-job-of-another-profile + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + file-id (first (:file-ids (import-fixture! owner))) + job-id (:id (:result (create-export-binfile-job (:id owner) #{file-id})))] + + (t/testing "another profile gets the answer of a job that does not exist" + (let [out (cancel-job (:id other) job-id)] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + + (t/testing "and the job keeps running untouched" + (t/is (pos? (claim-job! job-id))) + (t/is (= "running" (:status (jobs/get-job th/*system* job-id))))))) + +(t/deftest cancel-job-answers-a-finished-job-without-error + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (:id (:result (create-export-binfile-job (:id profile) #{file-id})))] + + (t/is (pos? (claim-job! job-id))) + (t/is (pos? (jobs/complete th/*system* :job-id job-id + :result {:value 42}))) + + (t/testing "cancelling what is already over answers its state" + (let [out (cancel-job (:id profile) job-id)] + (t/is (th/success? out)) + (t/is (= "completed" (:status (:result out)))))))) + +(t/deftest cancel-job-rejects-a-malformed-id + (let [profile (th/create-profile* 1) + out (cancel-job (:id profile) "nope")] + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))))) diff --git a/backend/test/backend_tests/rpc_management_jobs_test.clj b/backend/test/backend_tests/rpc_management_jobs_test.clj index d129e9687c..9ff0882e68 100644 --- a/backend/test/backend_tests/rpc_management_jobs_test.clj +++ b/backend/test/backend_tests/rpc_management_jobs_test.clj @@ -74,7 +74,7 @@ WHERE job_id = ? AND kind = 'progress' ORDER BY created_at ASC, id ASC" id]) - (mapv #(db/decode-json-pgobject (:payload %))))) + (mapv #(jobs/decode-progress (:payload %))))) (defn- mgmt [type params] @@ -138,17 +138,20 @@ (let [job-id (mk-job {}) _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) out (mgmt :report-job-progress {:job-id job-id - :progress {:total 100 :current 50}})] + :progress {:stage :pages + :counters {:pages {:current 50 :total 100}}}})] (t/is (nil? (:error out))) (t/is (= {:action :run} (:result out))) - (t/is (= [{:total 100 :current 50}] (get-progresss job-id))) + (t/is (= [{:stage :pages :counters {:pages {:current 50 :total 100}}}] + (get-progresss job-id))) (t/testing "the job row has no progress column anymore" (t/is (not (contains? (get-row job-id) :progress)))))) (t/deftest report-job-progress-noop-on-terminal-row (let [job-id (mk-job {:status "completed"}) out (mgmt :report-job-progress {:job-id job-id - :progress {:total 100 :current 100}})] + :progress {:stage :pages + :counters {:pages {:current 100 :total 100}}}})] (t/is (nil? (:error out))) (t/is (= {:action :skip} (:result out))) (t/is (= [] (get-progresss job-id))))) @@ -157,25 +160,27 @@ (let [job-id (mk-job {}) _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) _ (mgmt :report-job-progress {:job-id job-id - :progress {:total 10 :current 3}}) + :progress {:stage :pages + :counters {:pages {:current 3 :total 10}}}}) _ (mgmt :report-job-progress {:job-id job-id - :progress {:total 10 :current 4}})] + :progress {:stage :pages + :counters {:pages {:current 4 :total 10}}}})] (t/testing "the second immediate report is not throttled away" - (t/is (= [{:total 10 :current 3} - {:total 10 :current 4}] + (t/is (= [{:stage :pages :counters {:pages {:current 3 :total 10}}} + {:stage :pages :counters {:pages {:current 4 :total 10}}}] (get-progresss job-id)))))) (t/deftest report-job-progress-validates-the-payload (let [job-id (mk-job {}) _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at)))] - (t/testing "current is mandatory" + (t/testing "stage is mandatory" (t/is (= :validation (th/ex-type (:error (mgmt :report-job-progress {:job-id job-id - :progress {:total 10}})))))) + :progress {:counters {:work {:current 1}}}})))))) (t/testing "no extra key is accepted" (t/is (= :validation (th/ex-type (:error (mgmt :report-job-progress {:job-id job-id - :progress {:current 1 + :progress {:stage :work :message "boom"}})))))) (t/is (= [] (get-progresss job-id))))) @@ -243,7 +248,7 @@ (jobs/claim cfg job-id1 (:scheduled-at (th/db-get :job {:id job-id1} :id :scheduled-at))) (jobs/claim cfg job-id2 (:scheduled-at (th/db-get :job {:id job-id2} :id :scheduled-at))) (jobs/heartbeat cfg :job-id job-id1) - (jobs/heartbeat cfg :job-id job-id2 :progress {:current 1}) + (jobs/heartbeat cfg :job-id job-id2 :progress {:stage :work}) (jobs/complete cfg :job-id job-id1) (jobs/fail cfg job-id2 test-error) (t/is (not (contains? @@#'jobs/heartbeats job-id1))) @@ -300,7 +305,7 @@ (t/is (= {:action :skip} (:result out))))) (t/testing "progress on a terminal row reports skip" (let [out (mgmt :report-job-progress {:job-id done-id - :progress {:total 10 :current 4}})] + :progress {:stage :pages :counters {:pages {:current 4 :total 10}}}})] (t/is (nil? (:error out))) (t/is (= {:action :skip} (:result out)))))) (let [cfg (make-cfg) @@ -309,7 +314,7 @@ (t/testing "live paths report run" (t/is (= {:action :run} (:result (mgmt :report-job-progress {:job-id job-id - :progress {:total 10 :current 4}})))) + :progress {:stage :pages :counters {:pages {:current 4 :total 10}}}})))) (t/is (= {:action :run} (:result (mgmt :complete-job {:job-id job-id :result {:x 1}}))))))) diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index c691ea678b..84493e1ed4 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -6,9 +6,12 @@ (ns backend-tests.rpc-quotes-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.http :as http] + [app.jobs :as jobs] [app.rpc :as-alias rpc] [app.rpc.cond :as cond] [app.rpc.quotes :as-alias quotes] @@ -472,3 +475,78 @@ (t/is (= :restriction (th/ex-type error))) (t/is (= :max-quote-reached (th/ex-code error))) (t/is (= "media-storage-bytes-per-team" (:target (ex-data error)))))))) + +(t/deftest binfile-jobs-per-profile-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-export-jobs-per-profile 2 + :quotes-import-jobs-per-profile 1})}] + + (let [profile (th/create-profile* 1) + other (th/create-profile* 2) + + insert! (fn [profile-id name status & {:keys [tenant]}] + (th/db-insert! :job {:id (uuid/next) + :name name + :tenant (or tenant (cf/get :tenant)) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status status + :profile-id profile-id + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)})) + + check! (fn check! + ([id profile-id] (check! th/*system* id profile-id)) + ([cfg id profile-id] + (quotes/check! cfg {::quotes/id id + ::quotes/profile-id profile-id}))) + + error (fn [& args] + (try (apply check! args) nil (catch Throwable cause cause)))] + + (t/testing "a profile without jobs is under the quote" + (t/is (nil? (check! ::quotes/export-jobs-per-profile (:id profile))))) + + (t/testing "the jobs of another profile do not count" + (insert! (:id other) "export-binfile" "new") + (insert! (:id profile) "export-binfile" "new") + (t/is (nil? (check! ::quotes/export-jobs-per-profile (:id profile))))) + + (t/testing "the jobs of another tenant do not count either" + (insert! (:id profile) "export-binfile" "new" :tenant "another-tenant") + (t/is (nil? (check! ::quotes/export-jobs-per-profile (:id profile))))) + + (t/testing "a job that already reached a terminal state does not count" + (insert! (:id profile) "export-binfile" "completed") + (insert! (:id profile) "export-binfile" "failed") + (insert! (:id profile) "export-binfile" "cancelled") + (t/is (nil? (check! ::quotes/export-jobs-per-profile (:id profile))))) + + (t/testing "the job that would go over the limit is rejected" + (insert! (:id profile) "export-binfile" "running") + (let [cause (error ::quotes/export-jobs-per-profile (:id profile))] + (t/is (some? cause)) + (t/is (= :restriction (:type (ex-data cause)))) + (t/is (= :max-quote-reached (:code (ex-data cause)))) + (t/is (= "export-jobs-per-profile" (:target (ex-data cause)))))) + + (t/testing "the families are independent" + (t/is (nil? (check! ::quotes/import-jobs-per-profile (:id profile)))) + (insert! (:id profile) "import-binfile" "new") + (t/is (some? (error ::quotes/import-jobs-per-profile (:id profile))))) + + (t/testing "a new name of the family is covered by the registry" + (let [future (th/create-profile* 3) + defs (assoc (::jobs/defs th/*system*) + :export-assets {::jobs/name :export-assets + ::jobs/family :export}) + cfg (assoc th/*system* ::jobs/defs defs)] + (insert! (:id future) "export-assets" "new") + (t/is (nil? (check! cfg ::quotes/export-jobs-per-profile (:id future)))) + (insert! (:id future) "export-assets" "new") + (t/is (some? (error cfg ::quotes/export-jobs-per-profile (:id future))))))))) diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index a49d5b4717..930e059433 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -113,6 +113,44 @@ :content-type "text/plain"})] (t/is (not= (:id object1) (:id object2))))) +(t/deftest job-resource-objects-require-an-owner + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content")] + + (t/testing "an object of this bucket cannot be stored without its owner" + (t/is (thrown? Throwable + (sto/put-object! storage {::sto/content content + :bucket sto/job-resource-bucket + :content-type "text/plain"})))) + + (t/testing "and storing it with an owner still works" + (t/is (some? (sto/put-object! storage {::sto/content content + :bucket sto/job-resource-bucket + :profile-id (uuid/next) + :content-type "text/plain"})))))) + +(t/deftest job-resource-objects-are-not-deduplicated + ;; An object in this bucket belongs to one profile: sharing a blob + ;; between two owners would serve one user's artifact to another. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + ::sto/touched-at (ct/now) + :bucket sto/job-resource-bucket + :profile-id (uuid/next) + :content-type "text/plain"}) + object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + ::sto/touched-at (ct/now) + :bucket sto/job-resource-bucket + :profile-id (uuid/next) + :content-type "text/plain"})] + (t/is (not= (:id object1) (:id object2))))) + (t/deftest put-and-retrieve-expired-object (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) @@ -1271,6 +1309,7 @@ job-id (uuid/next) object (sto/put-object! storage {::sto/content (sto/content "content") :content-type "text/plain" + :profile-id (uuid/next) :bucket sto/job-resource-bucket})] ;; the object is created with the job-resource bucket in metadata @@ -1319,6 +1358,7 @@ ;; a touched object with no referencing job row at all is deleted too (let [object (sto/put-object! storage {::sto/content (sto/content "content") :content-type "text/plain" + :profile-id (uuid/next) :bucket sto/job-resource-bucket})] (th/db-update! :storage-object {:touched-at (ct/now)} {:id (:id object)}) (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] diff --git a/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj b/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj new file mode 100644 index 0000000000..54a6372163 --- /dev/null +++ b/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj @@ -0,0 +1,268 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.tasks.binfile-jobs-integration-test + "The whole chain of a binfile job, without a full end-to-end: the + command creates the job, the dispatcher sends it to its own queue, the + runner of that queue executes it and the artifact it produced (or + consumed) ends up where the ledger says." + (:require + [app.binfile.common :as bfc] + [app.binfile.jobs :as bfj] + [app.binfile.v3 :as v3] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.metrics :as-alias mtx] + [app.msgbus :as-alias mbus] + [app.redis :as rds] + [app.rpc :as-alias rpc] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [app.worker :as wrk] + [app.worker.dispatcher :as wdisp] + [app.worker.runner :as wrkr] + [backend-tests.helpers :as th] + [clojure.java.io :as jio] + [clojure.test :as t] + [datoteka.io :as io])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) + +(def ^:private fixture + "A real penpot export, the same fixture the binfile tests read." + "backend_tests/test_files/svg-attrs-camel-case.penpot") + +(def ^:private fast-timeout + "A runner of an empty queue must not make the test wait for the default + timeout of the production loop." + (ct/duration {:millis 200})) + +(defn- get-storage + [] + (assoc (:app.storage/storage th/*system*) ::sto/backend :fs)) + +(defn- import-fixture! + "A file in the default project of the profile, imported from the + fixture through the core." + [profile] + (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input (-> fixture io/resource jio/file)) + (v3/import-files!))) + +(defn- upload-chunked! + "Upload the fixture as a single chunk and return the session id." + [profile] + (let [source (-> fixture io/resource jio/file) + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id profile) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id profile) + :session-id session-id + :index 0 + :content {:filename "package.penpot" + :path (th/tempfile fixture) + :mtype "application/zip" + :size (.length source)}})] + (t/is (nil? (:error out))) + session-id)) + +(defn- dispatcher-cfg + [] + {::db/pool th/*pool* + ::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics) + ::wrk/tenant (cf/get :tenant) + ::wdisp/lease (cf/get-jobs-lease) + ::wdisp/timeout (ct/duration "10s")}) + +(defn- runner-cfg + "The cfg the runner loop needs, with the real job-defs of the system: + the handler closes over the component of its own job-def." + [queue] + {::db/pool th/*pool* + ::rds/conn (rds/connect {::rds/client (get th/*system* :app.redis/client) + ::mtx/metrics (get th/*system* :app.metrics/metrics)}) + ::jobs/defs (::jobs/defs th/*system*) + ::mtx/metrics (get th/*system* :app.metrics/metrics) + ::mbus/msgbus (get th/*system* :app.msgbus/msgbus) + ::wrkr/id "test-runner" + ::wrkr/queue queue + ::wrk/tenant (cf/get :tenant) + ::wrkr/timeout fast-timeout}) + +(defn- run-one + [cfg] + (@#'wrkr/run-worker-loop cfg)) + +(defn- job-row + [job-id] + (-> (th/db-get :job {:id job-id}) + (update :result #(cond-> % (db/pgobject? %) db/decode-json-pgobject)) + (update :error jobs/decode-job-error))) + +(defn- project-files + [project-id] + (th/db-exec! ["SELECT id FROM file WHERE project_id = ? AND deleted_at IS NULL" + project-id])) + +(defn- download + "The artifact as a local file, as a client would fetch it." + [resource-id] + (let [object (sto/get-object (get-storage) resource-id) + path (tmp/tempfile* :suffix ".penpot")] + (with-open [out (io/output-stream path)] + (io/copy (sto/get-object-data (get-storage) object) out)) + path)) + +(defn- create-export-binfile-job + [profile file-ids] + (th/command! {::th/type :create-export-binfile-job + ::rpc/profile-id (:id profile) + :params {:file-ids file-ids + :export-type :detach-libraries}})) + +(defn- create-import-binfile-job + [profile project-id] + (th/command! {::th/type :create-import-binfile-job + ::rpc/profile-id (:id profile) + :params {:project-id project-id + :name "imported"} + :upload-id (upload-chunked! profile)})) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-export-job-runs-to-completion-in-its-own-queue + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + out (create-export-binfile-job profile #{file-id}) + job-id (:id (:result out))] + + (t/is (th/success? out)) + + (wdisp/run-batch (dispatcher-cfg)) + (t/testing "the dispatcher schedules it, and it waits for its queue" + (t/is (= "scheduled" (:status (jobs/get-job th/*system* job-id))))) + + (t/testing "a runner of another queue does not take it" + (run-one (runner-cfg "default")) + (t/is (= "scheduled" (:status (jobs/get-job th/*system* job-id))))) + + (t/testing "the runner of its queue does" + (run-one (runner-cfg "binfile")) + (t/is (= "completed" (:status (jobs/get-job th/*system* job-id))))) + + (let [row (job-row job-id) + job (:result row)] + (t/testing "the runner associated the artifact of the completion envelope" + (t/is (some? (:resource-id row)))) + + (t/testing "the result of the job is the descriptor the handler built" + (t/is (= "export.penpot" (:filename job))) + (t/is (= "application/zip" (:mtype job))) + (t/is (pos? (:size job))) + (t/is (some? (:resource-uri job)))) + + (t/testing "and the artifact is the package of those files" + (t/is (= [file-id] + (mapv :id (:files (v3/get-manifest th/*system* + (download (:resource-id row)))))))) + + (t/testing "the artifact belongs to the profile of the job" + (let [object (sto/get-object (get-storage) (:resource-id row))] + (t/is (= sto/job-resource-bucket (:bucket (meta object)))) + (t/is (= (:id profile) (:profile-id (meta object)))))) + + (t/testing "the runner reached the terminal state through the row" + (t/is (= "completed" (:status row))) + (t/is (some? (:completed-at row))))))) + +(t/deftest the-import-job-runs-to-completion-in-its-own-queue + (let [profile (th/create-profile* 1) + project-id (:default-project-id profile) + out (create-import-binfile-job profile project-id) + job-id (:id (:result out)) + ;; read before the run: the handler drops the reference as soon as + ;; it releases the package + input-id (:resource-id (th/db-get :job {:id job-id}))] + + (t/is (th/success? out)) + + (wdisp/run-batch (dispatcher-cfg)) + (run-one (runner-cfg "binfile")) + + (let [row (job-row job-id) + job (:result row)] + (t/testing "the job ends completed" + (t/is (= "completed" (:status row)))) + + (t/testing "the files of the package are in the destination" + ;; the result is JSON: its ids come back as text, the rows as uuids + (t/is (= (set (map str (:file-ids job))) + (set (map (comp str :id) (project-files project-id)))))) + + (t/testing "the result carries the files and their resolution" + (t/is (pos? (count (:file-ids job)))) + (t/is (= "imported" (:name job)))) + + (t/testing "the package it consumed is released" + (t/is (some? (:deleted-at (th/db-get :storage-object + {:id input-id} + {::db/remove-deleted false}))))) + + (t/testing "and the job no longer points at it" + (t/is (nil? (:resource-id row))))))) + +(t/deftest the-adapter-reports-progress-through-heartbeat + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (uuid/next) + calls (atom [])] + + (th/db-insert! :job {:id job-id + :name "export-binfile" + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id (:id profile) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (with-redefs [jobs/heartbeat (fn [_cfg & {:as options}] + (swap! calls conj options) + 1)] + (bfj/export-files th/*system* + (jobs/make-context (jobs/get-job th/*system* job-id)) + {:ids #{file-id} + :export-type :detach-libraries + :output (tmp/tempfile* :suffix ".penpot")})) + + (let [reports (map :progress @calls)] + + (t/testing "the adapter reports progress through heartbeat" + (t/is (pos? (count reports)))) + + (t/testing "with the payload of the job contract, and nothing else" + (t/is (every? keyword? (map :stage reports))) + (t/is (every? #(<= (count (keys %)) 2) reports))) + + (t/testing "about the job of the context, not one the caller named" + (t/is (every? #(= job-id (:job-id %)) @calls)))))) diff --git a/backend/test/backend_tests/tasks/export_binfile_test.clj b/backend/test/backend_tests/tasks/export_binfile_test.clj new file mode 100644 index 0000000000..80fcdedd36 --- /dev/null +++ b/backend/test/backend_tests/tasks/export_binfile_test.clj @@ -0,0 +1,193 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.tasks.export-binfile-test + "The `:export-binfile` job: what it produces, who may ask for it and + what it leaves behind when it fails." + (:require + [app.binfile.common :as bfc] + [app.binfile.v3 :as v3] + [app.common.exceptions :as ex] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [backend-tests.helpers :as th] + [clojure.java.io :as jio] + [clojure.test :as t] + [cuerdas.core :as str] + [datoteka.io :as io])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) + +(def ^:private fixture + "A real penpot export, the same fixture the binfile tests read." + "backend_tests/test_files/svg-attrs-camel-case.penpot") + +(defn- get-storage + [] + (assoc (:app.storage/storage th/*system*) ::sto/backend :fs)) + +(defn- import-fixture! + "A file in the default project of the profile, imported from the + fixture through the core." + [profile] + (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input (-> fixture io/resource jio/file)) + (v3/import-files!))) + +(defn- make-job! + "A job row as the substrate writes it: running and owned by a profile." + [profile-id] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name "export-binfile" + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + +(defn- run-export + "Run the export handler of a job, as the runner does." + [job-id params] + (jobs/invoke (-> th/*system* + (assoc ::jobs/name :export-binfile) + (assoc ::jobs/params params) + (assoc ::jobs/context + (jobs/make-context (jobs/get-job th/*system* job-id)))))) + +(defn- count-storage-objects + [] + (count (th/db-exec! ["SELECT id FROM storage_object"]))) + +(defn- temp-files + "The temporary files the export leaves in the tmp directory." + [] + (seq (.list (jio/file tmp/default-tmp-dir)))) + +(defn- download + "The artifact as a local file, as a client would fetch it." + [resource-id] + (let [object (sto/get-object (get-storage) resource-id) + path (tmp/tempfile* :suffix ".penpot")] + (with-open [out (io/output-stream path)] + (io/copy (sto/get-object-data (get-storage) object) out)) + path)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-export-handler-stores-the-artifact-of-its-job + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile)) + result (run-export job-id {:file-ids #{file-id} + :export-type :detach-libraries}) + object (sto/get-object (get-storage) (:resource-id result))] + + (t/testing "the handler answers with the completion envelope" + (t/is (uuid? (:resource-id result))) + (t/is (= "export.penpot" (get-in result [:result :filename]))) + (t/is (= "application/zip" (get-in result [:result :mtype]))) + (t/is (pos? (get-in result [:result :size]))) + (t/is (str/includes? (get-in result [:result :resource-uri]) + (str (:resource-id result))))) + + (t/testing "the artifact is a job resource owned by the profile of the job" + (t/is (= sto/job-resource-bucket (:bucket (meta object)))) + (t/is (= (:id profile) (:profile-id (meta object))))) + + (t/testing "it is touched, so a crash before the completion leaves it reclaimable" + (t/is (some? (:touched-at (th/db-get :storage-object + {:id (:resource-id result)}))))) + + (t/testing "the package is the one the core produces for those files" + (let [manifest (v3/get-manifest th/*system* (download (:resource-id result)))] + (t/is (= [file-id] (mapv :id (:files manifest)))))) + + (t/testing "the completion of the job associates the artifact" + (jobs/complete th/*system* + :job-id job-id + :result (:result result) + :resource-id (:resource-id result)) + (t/is (= (:resource-id result) + (:resource-id (jobs/get-job th/*system* job-id))))))) + +(t/deftest the-export-handler-revalidates-the-read-permission + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + file-id (first (:file-ids (import-fixture! owner))) + job-id (make-job! (:id other)) + before (count-storage-objects)] + + (t/testing "a job whose owner cannot read a file does not export it" + (t/is (thrown? Throwable + (run-export job-id {:file-ids #{file-id} + :export-type :detach-libraries})))) + + (t/testing "and nothing was stored for it" + (t/is (= before (count-storage-objects))) + (t/is (nil? (:resource-id (jobs/get-job th/*system* job-id))))) + + (t/testing "the temporary file is not left behind either" + (t/is (empty? (temp-files)))))) + +(t/deftest the-export-handler-leaves-nothing-when-the-storage-fails + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile)) + before (count-storage-objects)] + + (with-redefs [sto/put-object! (fn [& _] + (ex/raise :type :internal + :code :boom + :hint "storage is down"))] + (t/is (thrown? Throwable + (run-export job-id {:file-ids #{file-id} + :export-type :detach-libraries})))) + + (t/testing "the job never comes to own an artifact" + (t/is (nil? (:resource-id (jobs/get-job th/*system* job-id))))) + + (t/testing "no artifact was stored" + (t/is (= before (count-storage-objects)))) + + (t/testing "and the temporary file of the export is gone" + (t/is (empty? (temp-files)))))) + +(t/deftest the-export-handler-leaves-nothing-behind-when-the-job-is-cancelled + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + job-id (make-job! (:id profile)) + before (count-storage-objects)] + + (th/db-update! :job {:status "cancelled"} {:id job-id}) + + (t/is (thrown? Throwable + (run-export job-id {:file-ids #{file-id} + :export-type :detach-libraries}))) + + (t/testing "no artifact was stored" + (t/is (= before (count-storage-objects)))) + + (t/testing "and the temporary file of the export was cleaned up" + (t/is (empty? (temp-files)))))) diff --git a/backend/test/backend_tests/tasks/import_binfile_test.clj b/backend/test/backend_tests/tasks/import_binfile_test.clj new file mode 100644 index 0000000000..e2c619177f --- /dev/null +++ b/backend/test/backend_tests/tasks/import_binfile_test.clj @@ -0,0 +1,254 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.tasks.import-binfile-test + "The `:import-binfile` job: what it imports, who may ask for it and what + it leaves behind when it fails." + (:require + [app.binfile.common :as bfc] + [app.binfile.v1 :as v1] + [app.binfile.v3 :as v3] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.jobs.storage :as js] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [backend-tests.helpers :as th] + [clojure.java.io :as jio] + [clojure.test :as t] + [datoteka.io :as io])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) + +(def ^:private fixture + "A real penpot export, the same fixture the binfile tests read." + "backend_tests/test_files/svg-attrs-camel-case.penpot") + +(defn- fixture-path + [] + (-> fixture io/resource jio/file)) + +(defn- import-fixture! + "A file in the default project of the profile, imported from the + fixture through the core." + [profile] + (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input (fixture-path)) + (v3/import-files!))) + +(defn- export-to-v1! + "A version 1 package of a file, the format the old exporter writes." + [file-id] + (let [output (tmp/tempfile* :suffix ".penpot")] + (v1/export-files! (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/include-libraries false) + (assoc ::bfc/embed-assets false)) + output) + output)) + +(defn- stage-package! + "The package a job consumes, as its resource." + [profile path] + (js/put-resource th/*system* (:id profile) + {:content (sto/content path) + :filename "package.penpot" + :mtype "application/zip"})) + +(defn- make-job! + "A job row as the substrate writes it: running, owned by a profile and + holding the package it consumes." + [profile-id resource-id] + (let [id (uuid/next)] + (th/db-insert! :job {:id id + :name "import-binfile" + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :resource-id resource-id + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + +(defn- run-import + "Run the import handler of a job, as the runner does." + [job-id params] + (jobs/invoke (-> th/*system* + (assoc ::jobs/name :import-binfile) + (assoc ::jobs/params params) + (assoc ::jobs/context + (jobs/make-context (jobs/get-job th/*system* job-id)))))) + +(defn- project-files + [project-id] + (th/db-exec! ["SELECT id FROM file WHERE project_id = ? AND deleted_at IS NULL" + project-id])) + +(defn- input-row + [resource-id] + (th/db-get :storage-object {:id resource-id} {::db/remove-deleted false})) + +(defn- temp-files + [] + (seq (.list (jio/file tmp/default-tmp-dir)))) + +(defn- project-modified-at + [project-id] + (:modified-at (th/db-get :project {:id project-id}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-import-handler-imports-the-package-of-its-job + (let [profile (th/create-profile* 1) + staged (stage-package! profile (fixture-path)) + job-id (make-job! (:id profile) (:resource-id staged)) + result (run-import job-id {:project-id (:default-project-id profile) + :name "imported" + :version 3})] + + (t/testing "the handler answers with the files it created" + (t/is (= 1 (count (:file-ids result)))) + (t/is (= "imported" (:name result))) + (t/is (= 3 (:version result))) + (t/testing "and the file is in the destination project" + (t/is (= (set (:file-ids result)) + (set (map :id (project-files (:default-project-id profile)))))))) + + (t/testing "the package of the job is released once the job is terminal" + (t/is (some? (:deleted-at (input-row (:resource-id staged)))))) + + (t/testing "and its temporary copy is gone" + (t/is (empty? (temp-files)))))) + +(t/deftest the-import-marks-the-destination-project-as-modified + (let [profile (th/create-profile* 1) + project-id (:default-project-id profile) + staged (stage-package! profile (fixture-path)) + job-id (make-job! (:id profile) (:resource-id staged)) + before (project-modified-at project-id)] + + (run-import job-id {:project-id project-id + :name "imported" + :version 3}) + + (t/testing "the project the import wrote into is not left looking untouched" + (t/is (pos? (- (inst-ms (project-modified-at project-id)) + (inst-ms before))))))) + +(t/deftest the-import-handler-imports-a-version-1-package + (let [profile (th/create-profile* 1) + file-id (first (:file-ids (import-fixture! profile))) + staged (stage-package! profile (export-to-v1! file-id)) + job-id (make-job! (:id profile) (:resource-id staged)) + result (run-import job-id {:project-id (:default-project-id profile) + :name "imported-v1" + :version 1})] + + (t/is (= 1 (count (:file-ids result)))) + (t/is (= 1 (:version result))))) + +(t/deftest the-import-handler-revalidates-the-edit-permission + (let [owner (th/create-profile* 1) + other (th/create-profile* 2) + staged (stage-package! other (fixture-path)) + ;; the package belongs to `other`, but the destination project + ;; belongs to `owner`: an import there is not allowed + job-id (make-job! (:id other) (:resource-id staged)) + before (project-files (:default-project-id owner))] + + (t/testing "a job that cannot edit the destination does not import it" + (t/is (thrown? Throwable + (run-import job-id {:project-id (:default-project-id owner) + :name "imported" + :version 3})))) + + (t/testing "nothing was written in the destination" + (t/is (= before (project-files (:default-project-id owner))))) + + (t/testing "the package is released anyway: the job is terminal" + (t/is (some? (:deleted-at (input-row (:resource-id staged)))))))) + +(t/deftest a-job-that-was-already-cancelled-never-imports + (let [profile (th/create-profile* 1) + staged (stage-package! profile (fixture-path)) + job-id (make-job! (:id profile) (:resource-id staged)) + before (project-files (:default-project-id profile))] + + (th/db-update! :job {:status "cancelled"} {:id job-id}) + + (t/testing "a cancelled job does not import anything" + (t/is (thrown? Throwable + (run-import job-id {:project-id (:default-project-id profile) + :name "imported" + :version 3}))) + (t/is (= before (project-files (:default-project-id profile))))) + + (t/testing "and the package is released" + (t/is (some? (:deleted-at (input-row (:resource-id staged)))))))) + +(t/deftest an-import-cancelled-while-it-runs-leaves-nothing-behind + (let [profile (th/create-profile* 1) + staged (stage-package! profile (fixture-path)) + job-id (make-job! (:id profile) (:resource-id staged)) + before (project-files (:default-project-id profile))] + + ;; the user cancels while the import is running: the first progress + ;; report is a good moment for it, because the core is already + ;; writing by then + (with-redefs [jobs/heartbeat (fn [& _] + (th/db-update! :job {:status "cancelled"} {:id job-id}) + 1)] + (t/is (thrown? Throwable + (run-import job-id {:project-id (:default-project-id profile) + :name "imported" + :version 3})))) + + (t/testing "the import is rolled back: the destination keeps no files" + (t/is (= before (project-files (:default-project-id profile))))) + + (t/testing "and the package is released anyway" + (t/is (some? (:deleted-at (input-row (:resource-id staged)))))))) + +(t/deftest a-package-that-is-not-a-penpot-file-fails-without-writing + (let [profile (th/create-profile* 1) + staged (stage-package! profile (fixture-path)) + job-id (make-job! (:id profile) (:resource-id staged)) + before (project-files (:default-project-id profile))] + + ;; the bytes of the resource are replaced by something that is not a + ;; package at all: the core must fail instead of importing garbage + (sto/del-object! (:app.storage/storage th/*system*) (:resource-id staged)) + (let [broken (js/put-resource th/*system* (:id profile) + {:content (sto/content "not a penpot package") + :filename "package.penpot" + :mtype "application/zip"})] + (th/db-update! :job {:resource-id (:resource-id broken)} {:id job-id}) + + (t/is (thrown? Throwable + (run-import job-id {:project-id (:default-project-id profile) + :name "imported" + :version 3}))) + + (t/testing "nothing was imported" + (t/is (= before (project-files (:default-project-id profile))))) + + (t/testing "and the broken package is released too" + (t/is (some? (:deleted-at (input-row (:resource-id broken))))))))) diff --git a/backend/test/backend_tests/util_events_test.clj b/backend/test/backend_tests/util_events_test.clj new file mode 100644 index 0000000000..9c75d32699 --- /dev/null +++ b/backend/test/backend_tests/util_events_test.clj @@ -0,0 +1,77 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.util-events-test + "The events subsystem: a tap delivers to whatever sink is bound, a + function inline on the same thread or a channel for a listener of its + own, and untracked runs pay nothing." + (:require + [app.util.events :as events] + [clojure.test :as t] + [promesa.exec :as px] + [promesa.exec.csp :as sp])) + +(t/deftest tap-outside-a-run-is-a-no-op + (t/testing "a tap with no sink bound does nothing" + (t/is (nil? (events/tap :one 1))))) + +(t/deftest tap-calls-a-function-sink-inline + (let [seen (atom [])] + (binding [events/*sink* (fn [event] (swap! seen conj event))] + (t/testing "every tap reaches the function, in order" + (t/is (nil? (events/tap :one 1))) + (t/is (nil? (events/tap :two 2))) + (t/is (= [[:one 1] [:two 2]] @seen)))))) + +(t/deftest tap-puts-on-a-channel-sink + (let [channel (sp/chan :buf 4)] + (try + (binding [events/*sink* channel] + (t/testing "a tap puts the event on the channel" + (t/is (nil? (events/tap :one 1))) + (t/is (= [:one 1] (sp/take! channel))))) + (finally + (sp/close! channel))))) + +(t/deftest an-exception-from-a-function-sink-propagates + (let [cause (ex-info "the job is no longer active" + {:type :interrupt :code :job-interrupted})] + (binding [events/*sink* (fn [_event] (throw cause))] + (t/testing "the tap raises the very exception the sink threw" + (t/is (identical? cause + (try (events/tap :one 1) nil + (catch Throwable raised raised)))))))) + +(t/deftest spawn-listener-consumes-until-the-channel-closes + (let [seen (atom []) + ended (promise) + channel (sp/chan :buf 4) + listener (events/spawn-listener + channel + (fn [event] (swap! seen conj event)) + (fn [] (deliver ended true)))] + (sp/put! channel [:one 1]) + (sp/put! channel [:two 2]) + (sp/close! channel) + (px/await! listener) + (t/testing "the listener saw every event, in order" + (t/is (= [[:one 1] [:two 2]] @seen))) + (t/testing "and the close hook ran" + (t/is (true? (deref ended 1000 false)))))) + +(t/deftest spawn-listener-stops-on-a-consumer-failure + (let [seen (atom []) + channel (sp/chan :buf 4) + listener (events/spawn-listener + channel + (fn [_event] (throw (ex-info "boom" {}))) + (fn [] nil))] + (sp/put! channel [:one 1]) + (sp/put! channel [:two 2]) + (px/await! listener) + (t/testing "the failure stops the listener, so nothing is consumed" + (t/is (= [] @seen))) + (sp/close! channel))) diff --git a/backend/test/backend_tests/worker_runner_test.clj b/backend/test/backend_tests/worker_runner_test.clj index 415d444ab8..2df99415a3 100644 --- a/backend/test/backend_tests/worker_runner_test.clj +++ b/backend/test/backend_tests/worker_runner_test.clj @@ -221,14 +221,15 @@ (push-payload job-id scheduled-at) (run-one (mk-cfg {})) - (t/testing "the handler receives exactly the four context keys" + (t/testing "the handler receives exactly the five context keys" (t/is (= 1 (count @received-contexts))) (let [context (first @received-contexts)] - (t/is (= #{:id :name :label :resource-id} (set (keys context)))) + (t/is (= #{:id :name :label :resource-id :profile-id} (set (keys context)))) (t/is (= job-id (:id context))) (t/is (= "echo-runner" (:name context))) (t/is (= "runner-label" (:label context))) (t/is (nil? (:resource-id context))) + (t/is (nil? (:profile-id context)) "an internal job has no owner") (t/testing "and nothing of the row leaks into it" (t/is (not (contains? context :params))) diff --git a/backend/test/backend_tests/worker_test.clj b/backend/test/backend_tests/worker_test.clj index 75f87be0c4..02f525820d 100644 --- a/backend/test/backend_tests/worker_test.clj +++ b/backend/test/backend_tests/worker_test.clj @@ -7,6 +7,11 @@ (ns backend-tests.worker-test (:require [app.common.schema :as sm] + [app.config :as cf] + [app.db :as db] + [app.jobs :as jobs] + [app.main :as main] + [app.metrics :as-alias mtx] [app.msgbus :as mbus] [app.worker :as wrk] [app.worker.cron :as wcron] @@ -44,3 +49,20 @@ @#'wdisp/schema:dispatcher @#'wrkr/schema:params]] (t/is (sm/schema? (sm/schema s)))))) + +(t/deftest the-binfile-runner-is-wired + (let [cfg (get main/worker-config [:app.main/binfile ::wrk/runner])] + + (t/is (some? cfg)) + + (t/testing "the heavy work of a user job does not share the default runner" + (t/is (= :binfile (::wrk/queue cfg))) + (t/is (= (cf/get :worker-binfile-parallelism 1) (::wrk/parallelism cfg)))) + + (t/testing "a job of a profile cannot store its events without a msgbus" + (t/is (contains? cfg ::mbus/msgbus))) + + (t/testing "and the runner resolves jobs, metrics and the pool" + (t/is (contains? cfg ::jobs/defs)) + (t/is (contains? cfg ::mtx/metrics)) + (t/is (contains? cfg ::db/pool))))) diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index d50948e26e..5758f38ee4 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -10,9 +10,10 @@ [app.common.data :as d] [app.common.schema :as sm] [app.main.data.event :as ev] + [app.main.data.jobs :as dj] [app.main.data.modal :as modal] [app.main.repo :as rp] - [app.util.sse :as sse] + [app.main.store :as st] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -59,28 +60,73 @@ :team-id team-id :files files})))))))))) +(defn- export-file + "One file, one job: the file is exported on its own, so a failure does + not touch the others, and the artifact is read from the result of the + job once it is over. + + Every step of the job becomes a message for the caller: the milestone + under `:progress`, the artifact as `:uri` when the job completed, and + the public error of the job when it failed. The queue of a file is + announced by `export-files` before any job exists and only ends with a + milestone: a worker picking the job up is not one. + + `on-job` is an optional callback invoked with `{:job-id ... :file-id + ...}` for the created job, so the caller can track it per file and + cancel it while it runs." + [ws-conn type file on-job] + (->> (rp/cmd! :create-export-binfile-job + {:params {:file-ids #{(:id file)} + :export-type type}}) + (rx/mapcat (fn [{job-id :id}] + (when (fn? on-job) + (on-job {:job-id job-id :file-id (:id file)})) + (->> (dj/watch-job ws-conn job-id) + (rx/mapcat (fn [{:keys [kind status result error] :as emission}] + (cond + (= "completed" status) + (rx/of {:file-id (:id file) + :uri (:resource-uri result) + :filename (:name file)}) + + (= "failed" status) + (rx/of {:file-id (:id file) + :error error}) + + (= "cancelled" status) + (rx/of {:file-id (:id file) + :cancelled true}) + + (= :progress kind) + (rx/of {:file-id (:id file) + :progress (:payload emission)}) + + :else + (rx/empty))))))) + (rx/catch (fn [cause] + (rx/of {:file-id (:id file) + :error (ex-data cause)}))))) + (defn export-files - "Start files exportation process" - [& {:keys [type files]}] + "Start files exportation process. + + Every file is queued up front, even the ones whose job is only created + once the previous one is over, so a multiple export shows all of them + waiting from its first moment. + + The optional `:on-job` callback is invoked with `{:job-id ... + :file-id ...}` for every file job created, so the caller can track + them per file and cancel them while they run." + [& {:keys [type files on-job]}] (assert (check-export-files files) "expected a sequence of files") (assert (valid-types type) "expected valid export type") - (->> (rx/from files) - (rx/mapcat - (fn [file] - (->> (rp/cmd! ::sse/export-binfile {:file-id (:id file) - :version 3 - :type type}) - (rx/filter sse/end-of-stream?) - (rx/map sse/get-payload) - (rx/map (fn [uri] - {:file-id (:id file) - :uri uri - :filename (:name file)})) - (rx/catch (fn [cause] - (let [error (ex-data cause)] - (rx/of {:file-id (:id file) - :error error}))))))))) + (let [ws-conn (:ws-conn @st/state)] + (rx/concat + (rx/from (map (fn [file] {:file-id (:id file) :queued true}) files)) + (->> (rx/from files) + (rx/mapcat (fn [file] + (export-file ws-conn type file on-job))))))) ;;;;;;;;;;;;;;;;;;;;;; ;; Team Request diff --git a/frontend/src/app/main/data/imports.cljs b/frontend/src/app/main/data/imports.cljs new file mode 100644 index 0000000000..44cdd681d3 --- /dev/null +++ b/frontend/src/app/main/data/imports.cljs @@ -0,0 +1,294 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.data.imports + "Reading a penpot package and importing it as a job. + + Both steps run in the main thread: reading the manifest of the package + is cheap (it scales with the number of entries, not with the size of the + file) and the work itself belongs to the server, so the only thing the + client has to be near is the websocket that reports the progress of the + job." + (:require + [app.common.exceptions :as ex] + [app.common.json :as json] + [app.common.schema :as sm] + [app.common.uuid :as uuid] + [app.main.data.jobs :as dj] + [app.main.data.uploads :as uploads] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.http :as http] + [app.util.i18n :as i18n :refer [tr]] + [app.util.zip :as uz] + [beicon.v2.core :as rx] + [cuerdas.core :as str])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; MESSAGES OF A FAILURE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- resolve-hint + "The hint or the explanation of an error, trimmed, or nil." + [error] + (let [data (if (map? error) error (ex-data error))] + (or (some-> (:hint data) str/trim not-empty) + (some-> (:explain data) str/trim not-empty)))) + +(defn- cause-message + "The message of a failure of the transport: the hint the server sent, + then the explanation, then the message of the cause and finally the + generic one. The wrapper of an aborted stream says nothing useful, so it + is skipped." + [cause default] + (let [message (some-> (ex-message cause) str/trim)] + (or (resolve-hint cause) + (when-not (or (str/blank? message) (= message "stream exception")) + message) + default))) + +(defn- job-message + "The message of a job that failed." + [error] + (or (resolve-hint error) (tr "labels.error"))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; READING A PACKAGE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- slurp-uri + [uri response-type] + (->> (http/send! {:uri uri :response-type response-type :method :get}) + (rx/map :body))) + +(defn- parse-mtype + "The mime type behind the first bytes of a file, guessed the same way the + browser would: only a zip can be a penpot package of version 3." + [ba] + (let [u8 (js/Uint8Array. ba 0 4) + sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))] + (case sg + "120 113 3 4" "application/zip" + "1 13 32 206" "application/octet-stream" + "other"))) + +(def ^:private schema:manifest + [:map {:title "Manifest"} + [:type :string] + [:files + [:vector + [:map + [:id ::sm/uuid] + [:name :string]]]]]) + +(def ^:private decode-manifest + (sm/decoder schema:manifest sm/json-transformer)) + +(defn- read-zip-manifest + [zip-reader] + (->> (rx/from (uz/get-entry zip-reader "manifest.json")) + (rx/mapcat (fn [entry] + (if (nil? entry) + (rx/throw (ex/error :type :validation + :code :invalid-penpot-file + :hint "Not a valid Penpot file: manifest.json is missing")) + (uz/read-as-text entry)))) + (rx/map json/decode))) + +(defn- analyze-file + "The entries of one file the user dropped: one per file of a version 3 + package, one for a version 1 file, and an error for anything else." + [{:keys [uri] :as file}] + (let [stream (->> (slurp-uri uri :buffer) + (rx/merge-map + (fn [body] + (let [mtype (parse-mtype body)] + (cond + (= "application/zip" mtype) + (let [zip-reader (uz/reader body)] + (->> (read-zip-manifest zip-reader) + (rx/map + (fn [manifest] + (if (= (:type manifest) "penpot/export-files") + (let [manifest (decode-manifest manifest)] + (assoc file :type :binfile-v3 :files (:files manifest))) + (assoc file :type :unknown)))) + (rx/finalize (partial uz/close zip-reader)))) + + (= "application/octet-stream" mtype) + (rx/of (assoc file :type :binfile-v1)) + + :else + (rx/of (assoc file :type :unknown)))))) + (rx/share))] + + (->> (rx/merge + (->> stream + (rx/filter (fn [entry] (= :binfile-v1 (:type entry)))) + (rx/map (fn [entry] + (let [file-id (uuid/next)] + (-> entry + (assoc :file-id file-id) + (assoc :name (:name file)) + (assoc :status :success)))))) + + (->> stream + (rx/filter (fn [entry] (= :binfile-v3 (:type entry)))) + (rx/merge-map (fn [{:keys [files] :as entry}] + (->> (rx/from files) + (rx/map (fn [file] + (-> entry + (dissoc :files) + (assoc :name (:name file)) + (assoc :file-id (:id file)) + (assoc :status :success)))))))) + + (->> stream + (rx/filter (fn [data] (= :unknown (:type data)))) + (rx/map (fn [_] + {:uri (:uri file) + :status :error + :error (tr "dashboard.import.analyze-error")})))) + + (rx/catch (fn [cause] + (let [error (cause-message cause (tr "dashboard.import.analyze-error"))] + (rx/of (assoc file :error error :status :error)))))))) + +(defn analyze + "Read the manifest of every file the user dropped and answer with the + entries the wizard shows: one per file of a package." + [files] + (->> (rx/from files) + (rx/merge-map analyze-file))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMPORTING A PACKAGE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- packages + "The packages to import: a version 1 file is one package of one entry, + and the entries of a version 3 file share their upload, which the uri + of the dropped file identifies." + [entries] + (concat + (->> entries + (filter #(= :binfile-v1 (:type %))) + (map (fn [entry] + {:uri (:uri entry) + :version 1 + :name (str/replace (:name entry) #".penpot$" "") + :entries [entry]}))) + + (->> entries + (filter #(= :binfile-v3 (:type %))) + (group-by :uri) + (map (fn [[uri entries]] + {:uri uri + :version 3 + :name (:name (first entries)) + :entries entries}))))) + +(defn- entry-messages + "The message of every entry of a package: the job works on the package, + and each of its entries shows the same step." + [entries message] + (map (fn [entry] (assoc message :file-id (:file-id entry))) entries)) + +(defn- upload-milestone + "The milestone of the upload phase: the chunks done out of the total. + It is built on the client, never comes from the backend, and reuses + the milestone shape so the wizard shows it like any other step." + [{:keys [current total]}] + {:stage :upload :counters {:upload {:current current :total total}}}) + +(defn- import-package + "One package as a job: the blob is uploaded in chunks, the job is created + with the upload and followed, and its outcome becomes the messages of + the wizard. + + `on-job` is an optional callback invoked with the id of the created + job, so the caller can cancel it while it runs." + [ws-conn project-id resolutions {:keys [uri version name entries]} on-job] + (let [upload-events (rx/subject) + upload-ended? (atom false) + finish-upload! (fn [] + (when (compare-and-set! upload-ended? false true) + (rx/end! upload-events))) + report-upload! (fn [progress] + (doseq [message (entry-messages entries {:status :progress + :progress (upload-milestone progress)})] + (rx/push! upload-events message)))] + (rx/merge + upload-events + (->> (slurp-uri uri :blob) + (rx/mapcat (fn [blob] + (->> (uploads/upload-blob-chunked blob :on-progress report-upload!) + (rx/tap (fn [_] (finish-upload!)))))) + (rx/mapcat (fn [{:keys [session-id]}] + (rp/cmd! :create-import-binfile-job + {:params {:project-id project-id + :name name + :version version} + :upload-id session-id}))) + (rx/mapcat (fn [{job-id :id}] + (when (fn? on-job) + (on-job job-id)) + ;; the job exists but may wait for a worker: tell + ;; every entry it is queued before following it + (rx/concat + (rx/from (entry-messages entries {:status :queued})) + (->> (dj/watch-job ws-conn job-id) + (rx/mapcat (fn [{:keys [kind status result error] :as emission}] + (cond + (= "completed" status) + (do + (when-let [resolution (not-empty (:resolution result))] + (vswap! resolutions merge resolution)) + (rx/from (entry-messages entries {:status :finish}))) + + (= "failed" status) + (rx/from (entry-messages entries {:status :error + :error (job-message error)})) + + (= "cancelled" status) + (rx/from (entry-messages entries {:status :error + :error (tr "jobs.import-cancelled")})) + + (= :progress kind) + (rx/from (entry-messages entries {:status :progress + :progress (:payload emission)})) + + ;; a worker picked it up (or will + ;; retry it): no milestone yet + (contains? #{:start :retry} kind) + (rx/from (entry-messages entries {:status :started})) + + :else + (rx/empty)))))))) + (rx/catch (fn [cause] + (finish-upload!) + (rx/from (entry-messages entries {:status :error + :error (cause-message cause (tr "labels.error"))})))))))) + +(defn import-files + "Import the entries the wizard analyzed, one job per package. + + Answers with the messages the wizard already understands: the step of + the job for every entry (progress, finish or error) and, once every + package is over, the resolution of the libraries they brought. + + The optional `:on-job` callback is invoked with the id of every + package job created, so the caller can cancel them while they run." + [{:keys [project-id entries on-job]}] + (let [ws-conn (:ws-conn @st/state) + resolutions (volatile! {}) + importing (->> (rx/from (packages entries)) + (rx/merge-map (fn [package] + (import-package ws-conn project-id resolutions package on-job))))] + (rx/concat importing + (->> (rx/defer #(rx/of @resolutions)) + (rx/map (fn [resolution] + {:libraries-resolution resolution})))))) diff --git a/frontend/src/app/main/data/jobs.cljs b/frontend/src/app/main/data/jobs.cljs new file mode 100644 index 0000000000..9ffc801512 --- /dev/null +++ b/frontend/src/app/main/data/jobs.cljs @@ -0,0 +1,94 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.data.jobs + "Following a job the client created. + + A running job publishes its progress as `:job-event` messages on the + websocket of the profile and keeps its outcome in its own row. This + namespace joins both: `watch-job` emits every event of the job and one + final outcome, read from the row and not from the event, because the + closing event carries only the outcome." + (:require + [app.main.repo :as rp] + [app.util.websocket :as ws] + [beicon.v2.core :as rx])) + +(def ^:private terminal-statuses + #{"completed" "failed" "cancelled"}) + +(defn- events-of + "The events of one job, as they arrive on the websocket, with the + milestone of the event under `:payload`." + [ws-conn job-id] + (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/message-event?) + (rx/map :payload) + (rx/filter #(and (= :job-event (:type %)) + (= job-id (:job-id %)))) + (rx/map (fn [event] + {:kind (keyword (:kind event)) + :payload (:payload event)})))) + +(defn- outcome-of + "The row of a job as the outcome of the stream, nil while it is not + over. A job that failed answers with the public error of the row." + [job] + (when (contains? terminal-statuses (:status job)) + (cond-> {:status (:status job)} + (some? (:result job)) + (assoc :result (:result job)) + + (some? (:error job)) + (assoc :error (:error job))))) + +(defn- read-outcome + [job-id] + (->> (rp/cmd! :get-job {:id job-id}) + (rx/map outcome-of) + (rx/filter some?))) + +(defn watch-job + "The lifecycle of `job-id` as an observable: every event it publishes + and one final outcome, after which the stream ends. + + The outcome is read from the row and not from the closing event: it is + read once when the stream starts and again on every reconnect of the + websocket, so a job that ended before this stream existed, or while the + socket was down, still answers. A read that fails reaches the caller, + which is the only one that can tell a transient failure from a job that + is gone. + + Emissions: + + - `{:kind :progress :payload {:stage ... :counters {...}}}` per event, + with the kind of the event (`:progress`, `:start`, `:retry`) + - `{:status \"completed\" :result {...}}` or `{:status \"failed\" + :error {...}}` or `{:status \"cancelled\"}` once, and then the + stream completes" + [ws-conn job-id] + (let [events (events-of ws-conn job-id) + progress (->> events + (rx/filter #(not= :end (:kind %)))) + triggers (rx/merge (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/opened-event?)) + (->> events + (rx/filter #(= :end (:kind %))))) + outcomes (->> (rx/merge (rx/of ::start) triggers) + (rx/mapcat (fn [_] (read-outcome job-id))) + (rx/take 1) + (rx/share))] + (rx/merge (rx/take-until outcomes progress) + outcomes))) + +(defn cancel-job + "Ask the server to cancel a job the caller created. Fire and forget: + failures are ignored because the job may have just finished on its + own." + [job-id] + (->> (rp/cmd! :cancel-job {:id job-id}) + (rx/catch (fn [_] (rx/of nil))) + (rx/subs! (constantly nil) (constantly nil) (constantly nil)))) diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs index 5ae47c477a..f18956bd7f 100644 --- a/frontend/src/app/main/data/uploads.cljs +++ b/frontend/src/app/main/data/uploads.cljs @@ -11,7 +11,7 @@ by any feature that needs to upload large binary blobs: 1. create-upload-session – obtain a session-id - 2. upload-chunk – upload each slice (max-parallel-chunk-uploads in-flight) + 2. upload-chunk – upload each slice, one at a time 3. caller-specific step – e.g. assemble-file-media-object or import-binfile `upload-blob-chunked` drives steps 1 and 2 and emits the completed @@ -24,18 +24,13 @@ [app.main.repo :as rp] [beicon.v2.core :as rx])) -(def ^:private max-parallel-chunk-uploads - "Maximum number of chunk upload requests that may be in-flight at the - same time within a single chunked upload session." - 2) - (defn upload-blob-chunked "Uploads `blob` via the three-step chunked session API. Steps performed: 1. Creates an upload session (`create-upload-session`). - 2. Slices `blob` and uploads every chunk (`upload-chunk`), - with at most `max-parallel-chunk-uploads` concurrent requests. + 2. Slices `blob` and uploads every chunk (`upload-chunk`), one at a + time so the progress of the file is a straight line. Returns an observable that emits exactly one map: `{:session-id }` @@ -43,8 +38,11 @@ The caller is responsible for the final step (assemble / import). The optional `opts` map accepts: - `:chunk-size` – size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB)." - [blob & {:keys [chunk-size] :or {chunk-size cf/upload-chunk-size}}] + `:chunk-size` – size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB). + `:on-progress` – a fn called after every uploaded chunk with + `{:current :total }`, so the + caller can show the upload while it runs." + [blob & {:keys [chunk-size on-progress] :or {chunk-size cf/upload-chunk-size}}] (let [total-size (.-size blob) total-chunks (js/Math.ceil (/ total-size chunk-size))] (->> (rp/cmd! :create-upload-session @@ -53,17 +51,22 @@ (fn [{raw-session-id :session-id}] (let [session-id (cond-> raw-session-id (string? raw-session-id) uuid/uuid) + uploaded (atom 0) chunk-uploads (->> (range total-chunks) (map (fn [idx] (let [start (* idx chunk-size) end (min (+ start chunk-size) total-size) chunk (.slice blob start end)] - (rp/cmd! :upload-chunk - {:session-id session-id - :index idx - :content (list chunk (dm/str "chunk-" idx))})))))] + (->> (rp/cmd! :upload-chunk + {:session-id session-id + :index idx + :content (list chunk (dm/str "chunk-" idx))}) + (rx/tap (fn [_] + (when (fn? on-progress) + (on-progress {:current (swap! uploaded inc) + :total total-chunks})))))))))] (->> (rx/from chunk-uploads) - (rx/merge-all max-parallel-chunk-uploads) + (rx/concat-all) (rx/last) (rx/map (fn [_] {:session-id session-id}))))))))) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 01ce9e4c01..938e6aaa16 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -159,6 +159,7 @@ {:stream? true} :export-binfile {:response-type :blob} + :retrieve-list-of-builtin-templates {:query-params :all}}) (defn- send! diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index dd459da077..e03d67dbe9 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -13,6 +13,8 @@ [app.common.logging :as log] [app.main.data.dashboard :as dd] [app.main.data.event :as ev] + [app.main.data.imports :as imp] + [app.main.data.jobs :as dj] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.repo :as rp] @@ -29,7 +31,7 @@ [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.icons :as deprecated-icon] - [app.main.worker :as mw] + [app.main.ui.jobs.progress :as jp] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -108,45 +110,21 @@ (if (= (:file-id entry) (:file-id message)) (let [status (case (:status message) :progress :import-progress - :finish :import-success - :error :import-error)] - (-> entry - (assoc :progress (:progress message)) - (assoc :status status) - (assoc :error (:error message)) - (d/without-nils))) + :started :import-progress + :queued :import-queued + :finish :import-success + :error :import-error)] + ;; a message only carries what it knows: anything it does + ;; not bring (a stale milestone, an old error) is dropped + ;; instead of lingering on the row + (cond-> (assoc entry :status status) + (contains? message :progress) (assoc :progress (:progress message)) + (not (contains? message :progress)) (dissoc :progress) + (contains? message :error) (assoc :error (:error message)) + (not (contains? message :error)) (dissoc :error))) entry)) entries)) -(defn- parse-progress-message - [message] - (case (:type message) - :upload-data - (tr "dashboard.import.progress.upload-data" (:current message) (:total message)) - - :upload-media - (tr "dashboard.import.progress.upload-media" (:file message)) - - :process-page - (tr "dashboard.import.progress.process-page" (:file message)) - - :process-colors - (tr "dashboard.import.progress.process-colors") - - :process-typographies - (tr "dashboard.import.progress.process-typographies") - - :process-media - (tr "dashboard.import.progress.process-media") - - :process-components - (tr "dashboard.import.progress.process-components") - - :process-deleted-components - (tr "dashboard.import.progress.process-components") - - "")) - (defn- has-status-analyze? [item] (= (:status item) :analyze)) @@ -180,40 +158,32 @@ (defn- analyze-entries [state entries] - (let [features (get @st/state :features)] - (->> (mw/ask-many! - {:cmd :analyze-import - :files entries - :features features}) - (rx/mapcat #(rx/delay emit-delay (rx/of %))) - (rx/filter some?) - (rx/subs! - (fn [message] - (when (some? (:error message)) - (st/emit! (ev/event {::ev/name "import-files-error" - :error (:error message)}))) - (swap! state update-with-analyze-result message)))))) + (->> (imp/analyze entries) + (rx/mapcat #(rx/delay emit-delay (rx/of %))) + (rx/filter some?) + (rx/subs! + (fn [message] + (when (some? (:error message)) + (st/emit! (ev/event {::ev/name "import-files-error" + :error (:error message)}))) + (swap! state update-with-analyze-result message))))) (defn- import-files - [state library-resolution-data* project-id entries] + [state library-resolution-data* project-id entries on-job] (st/emit! (ev/event {::ev/name "import-files" :num-files (count entries)})) - (let [features (get @st/state :features)] - (->> (mw/ask-many! - {:cmd :import-files - :project-id project-id - :files entries - :features features}) - (rx/filter some?) - (rx/subs! - (fn [message] - ;; Capture library-resolution data if present (same for all - ;; entries from the same zip, so first one wins) - (if-let [resolution (-> (:libraries-resolution message) - (not-empty))] - (reset! library-resolution-data* resolution) - (swap! state update-entry-status message))))))) + (->> (imp/import-files {:project-id project-id + :entries entries + :on-job on-job}) + (rx/filter some?) + (rx/subs! + (fn [message] + ;; the resolution of the libraries of every package arrives once, + ;; when all of them are over + (if-let [resolution (-> (:libraries-resolution message) (not-empty))] + (reset! library-resolution-data* resolution) + (swap! state update-entry-status message)))))) (mf/defc import-entry* {::mf/memo true @@ -224,6 +194,7 @@ format (:type entry) loading? (or (= :analyze status) + (= :import-queued status) (= :import-progress status) (and is-progress (= :import-ready status))) analyze-error? (= :analyze-error status) @@ -336,24 +307,27 @@ [:> text* {:class (stl/css :error-message) :as "span" :typography t/body-small} - ;; backend-provided error key, dynamic by design - #_{:clj-kondo/ignore [:penpot/tr-dynamic]} + ;; the message is user-facing text already (a hint or a translated + ;; label), never a key (if (some? (:error entry)) - (tr (:error entry)) + (:error entry) (tr "dashboard.import.analyze-error"))] import-error? [:> text* {:class (stl/css :error-message) :as "span" :typography t/body-small} - ;; backend-provided error key, dynamic by design - #_{:clj-kondo/ignore [:penpot/tr-dynamic]} (if (some? (:error entry)) - (tr (:error entry)) + (:error entry) (tr "labels.error"))] + (and (= :import-queued status) (not import-success?)) + [:div {:class (stl/css :progress-message)} + (tr "jobs.queued")] + (and (not import-success?) (some? progress)) - [:div {:class (stl/css :progress-message)} (parse-progress-message progress)]) + [:div {:class (stl/css :progress-message)} + (jp/milestone-text progress :file? true)]) ;; This is legacy code, will be removed when legacy-zip format is removed [:div {:class (stl/css :linked-libraries)} @@ -577,7 +551,7 @@ {::mf/private true} [{:keys [entries template status errors? import-success-total auto-linked-count edition on-edit on-change on-delete - on-cancel on-continue on-accept pending-analysis?]}] + on-cancel on-cancel-import on-continue on-accept pending-analysis?]}] [:* [:div {:class (stl/css :modal-content)} (when (and (= :analyze status) errors?) @@ -639,13 +613,7 @@ (when (some? template) [:> import-entry* {:entry (assoc template :status status) - :can-be-deleted false}]) - - (when (= :import-progress status) - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} - (tr "labels.uploading-file")])] + :can-be-deleted false}])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} @@ -664,11 +632,16 @@ (tr "labels.continue")] :import-progress - [:> button* {:class (stl/css :accept-btn) - :variant "primary" - :disabled true - :on-click on-accept} - (tr "labels.accept")] + [:* + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-cancel-import} + (tr "labels.cancel")] + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled true + :on-click on-accept} + (tr "labels.accept")]] (:import-success :import-error) [:> button* {:class (stl/css :accept-btn) @@ -749,6 +722,13 @@ status* (mf/use-state :analyze) status (deref status*) + ;; Jobs created while importing, to cancel them on demand, and + ;; the subscription to their messages, to stop listening. + jobs* (mf/use-state #{}) + jobs (deref jobs*) + sub* (mf/use-state nil) + sub (deref sub*) + edition* (mf/use-state nil) edition (deref edition*) @@ -795,8 +775,10 @@ (mf/deps entries) (fn [] (let [entries (filterv has-status-ready? entries)] + (reset! jobs* #{}) (reset! status* :import-progress) - (import-files state* resolution* project-id entries)))) + (reset! sub* (import-files state* resolution* project-id entries + #(swap! jobs* conj %)))))) continue-template (mf/use-fn @@ -841,13 +823,33 @@ (fn [file-id] (swap! state* remove-entry file-id))) + on-cancel-import + (mf/use-fn + (mf/deps jobs sub) + (fn [event] + (dom/prevent-default event) + ;; stop listening first, so no late message repaints the + ;; entries of a dialog that is going away + (when (some? sub) + (rx/dispose! sub)) + ;; a job may have just finished on its own: failures are + ;; ignored and an empty run cancels nothing + (run! dj/cancel-job jobs) + (reset! jobs* #{}) + (reset! sub* nil) + (st/emit! (modal/hide)))) + on-cancel (mf/use-fn - (mf/deps edition) + (mf/deps edition status on-cancel-import) (fn [event] (when (nil? edition) - (dom/prevent-default event) - (st/emit! (modal/hide))))) + (if (= :import-progress status) + ;; closing mid-import stops the jobs, like Cancel + (on-cancel-import event) + (do + (dom/prevent-default event) + (st/emit! (modal/hide))))))) on-continue (mf/use-fn @@ -1013,6 +1015,7 @@ :on-change on-entry-change :on-delete on-entry-delete :on-cancel on-cancel + :on-cancel-import on-cancel-import :on-continue on-continue :on-accept on-accept :pending-analysis? pending-analysis?}] diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index f6765642b7..eafdc1ecc6 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -34,7 +34,7 @@ background-color: var(--color-background-primary); border: $b-2 solid var(--color-background-quaternary); min-block-size: $sz-192; - inline-size: $sz-512; + inline-size: px2rem(640); max-block-size: px2rem(800); } @@ -64,13 +64,6 @@ inset-inline-end: px2rem(6); } -.status-message { - @include use-typography("body-small"); - - color: var(--color-foreground-primary); - font-style: italic; -} - .action-buttons { display: flex; justify-content: flex-end; diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 92a458e772..a36545fb95 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.config :as cf] [app.main.data.exports.files :as fexp] + [app.main.data.jobs :as dj] [app.main.data.modal :as modal] [app.main.store :as st] [app.main.ui.ds.buttons.button :refer [button*]] @@ -20,6 +21,7 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.product.loader :refer [loader*]] + [app.main.ui.jobs.progress :as jp] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -30,7 +32,17 @@ [files file-id] (mapv #(cond-> % (= file-id (:id %)) - (assoc :export-error? true + (assoc :export-error true + :loading false)) + files)) + +(defn- mark-file-cancelled + "The job of a file was cancelled elsewhere: a neutral terminal state, + neither success nor error." + [files file-id] + (mapv #(cond-> % + (= file-id (:id %)) + (assoc :export-cancelled true :loading false)) files)) @@ -38,10 +50,27 @@ [files file-id] (mapv #(cond-> % (= file-id (:id %)) - (assoc :export-success? true + (assoc :export-success true :loading false)) files)) +(defn- mark-file-progress + "The milestone the job of a file is in, to show it while it runs." + [files file-id progress] + (mapv #(cond-> % + (= file-id (:id %)) + (assoc :progress progress + :queued false)) + files)) + +(defn- mark-file-queued + "The job of a file exists but no worker picked it up yet." + [files file-id] + (mapv #(cond-> % + (= file-id (:id %)) + (assoc :queued true)) + files)) + (defn- initialize-state "Initialize export dialog state" [files] @@ -54,26 +83,52 @@ {::mf/private true} [{:keys [file]}] (let [level (cond - (:export-success? file) :success - (:export-error? file) :error - :else :info)] + (:export-success file) :success + (:export-error file) :error + (:export-cancelled file) :warning + :else :info)] [:div {:class (stl/css-case :file-entry true :loading (:loading file) - :success (:export-success? file) - :error (:export-error? file))} + :success (:export-success file) + :error (:export-error file))} (if (:loading file) - [:div {:class (stl/css :file-name)} - [:> loader* {:width 26 - :title (tr "labels.loading")}] - [:> text* {:class (stl/css :file-name-label) - :as "span" - :typography t/body-large} - (:name file)]] + [:* + [:div {:class (stl/css :file-name)} + [:> loader* {:width 26 + :title (tr "labels.loading")}] + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-large} + (:name file)]] - [:> context-notification {:level level - :content (:name file)}])])) + ;; while the file is being produced: waiting for a worker, then + ;; the milestone of the job + (when (or (some? (:progress file)) (:queued file)) + (let [progress (:progress file)] + [:> text* {:class (stl/css :status-message) + :as "span" + :typography (if (some? progress) t/body-large t/body-medium) + :role "status" + :aria-live "polite"} + (if (some? progress) + (jp/milestone-text progress) + (tr "jobs.queued"))]))] + + [:* + [:> context-notification {:level level + :content (:name file)}] + + ;; a cancelled file is a neutral terminal state, but the label is + ;; what tells the user why no artifact came out + (when (:export-cancelled file) + [:> text* {:class (stl/css :status-message) + :as "span" + :typography t/body-medium + :role "status" + :aria-live "polite"} + (tr "jobs.export-cancelled")])])])) (mf/defc export-dialog {::mf/register modal/components @@ -90,25 +145,75 @@ selected (:selected state) status (:status state) + ;; Jobs still running, as file-id -> job-id, to cancel them on + ;; demand; a file that reaches its outcome leaves the map, so + ;; closing a finished export cancels nothing. And the + ;; subscription to their messages, to stop listening. + jobs* (mf/use-state {}) + jobs (deref jobs*) + sub* (mf/use-state nil) + sub (deref sub*) + start-export (mf/use-fn (mf/deps team-id selected files) (fn [] (swap! state* assoc :status :exporting) - (->> (fexp/export-files :files files :type selected) - (rx/subs! - (fn [{:keys [file-id error filename uri] :as result}] - (if error - (swap! state* update :files mark-file-error file-id) - (do - (swap! state* update :files mark-file-success file-id) - (dom/trigger-download-uri filename "application/penpot" uri)))))))) + (reset! jobs* {}) + (reset! sub* (->> (fexp/export-files :files files :type selected + :on-job #(swap! jobs* assoc (:file-id %) (:job-id %))) + (rx/subs! + (fn [{:keys [file-id error filename uri progress queued + cancelled]}] + (cond + (some? progress) + (swap! state* update :files mark-file-progress file-id progress) + + (true? queued) + (swap! state* update :files mark-file-queued file-id) + + (some? error) + (swap! jobs* dissoc file-id) + (swap! state* update :files mark-file-error file-id) + + (true? cancelled) + (swap! jobs* dissoc file-id) + (swap! state* update :files mark-file-cancelled file-id) + + ;; only a message carrying the artifact + ;; downloads: anything else is ignored + (some? uri) + (do + (swap! jobs* dissoc file-id) + (swap! state* update :files mark-file-success file-id) + (dom/trigger-download-uri filename "application/penpot" uri))))))))) + + on-cancel-export + (mf/use-fn + (mf/deps jobs sub) + (fn [event] + (dom/prevent-default event) + ;; stop listening first, so no late message repaints the + ;; entries of a dialog that is going away + (when (some? sub) + (rx/dispose! sub)) + ;; only the jobs still in flight are cancelled: finished ones + ;; already left the map, and failures are ignored because a job may have just finished on its own + (run! dj/cancel-job (vals jobs)) + (reset! jobs* {}) + (reset! sub* nil) + (st/emit! (modal/hide)))) on-cancel (mf/use-fn + (mf/deps status on-cancel-export) (fn [event] - (dom/prevent-default event) - (st/emit! (modal/hide)))) + (if (= :exporting status) + ;; closing mid-export stops the jobs, like Cancel + (on-cancel-export event) + (do + (dom/prevent-default event) + (st/emit! (modal/hide)))))) on-accept (mf/use-fn @@ -123,7 +228,7 @@ (let [type (-> (dom/get-target event) (dom/get-data "type") (keyword))] - (prn "AAA" selected type) + (swap! state* assoc :selected type))))] (mf/with-effect [has-libs?] @@ -208,16 +313,15 @@ [:* [:div {:class (stl/css :modal-content)} (for [file (:files state)] - [:> export-entry* {:file file :key (dm/str (:id file))}]) - - (when in-progress? - [:> text* {:as "span" :typography t/body-large :class (stl/css :status-message) - :role "status" - :aria-live "polite"} - (tr "labels.downloading-file")])] + [:> export-entry* {:file file :key (dm/str (:id file))}])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} + (when in-progress? + [:> button* {:variant "secondary" + :type "button" + :on-click on-cancel-export} + (tr "labels.cancel")]) [:> button* {:variant "primary" :type "button" :disabled in-progress? diff --git a/frontend/src/app/main/ui/jobs/progress.cljs b/frontend/src/app/main/ui/jobs/progress.cljs new file mode 100644 index 0000000000..83967aa978 --- /dev/null +++ b/frontend/src/app/main/ui/jobs/progress.cljs @@ -0,0 +1,69 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.jobs.progress + "How a milestone of a job is written in a row: the stage in course and + its counter. + + The stage is a stable key of the vocabulary of the worker, never a + display string, so it is resolved here with a `case` of literal `tr` + calls: a stage this client does not know falls back to a generic label + instead of breaking the row or showing the raw key." + (:require + [app.util.i18n :as i18n :refer [tr]])) + +(defn- stage-label + [stage] + (case stage + :files (tr "jobs.progress.stage.files") + :pages (tr "jobs.progress.stage.pages") + :media (tr "jobs.progress.stage.media") + :thumbnails (tr "jobs.progress.stage.thumbnails") + :components (tr "jobs.progress.stage.components") + :colors (tr "jobs.progress.stage.colors") + :typographies (tr "jobs.progress.stage.typographies") + :tokens (tr "jobs.progress.stage.tokens") + :storage-objects (tr "jobs.progress.stage.storage-objects") + :relations (tr "jobs.progress.stage.relations") + :metadata (tr "jobs.progress.stage.metadata") + :manifest (tr "jobs.progress.stage.manifest") + :upload (tr "jobs.progress.stage.upload") + (tr "jobs.progress.stage.unknown"))) + +(defn- counter-label + "The counter of one scope, `3/8` when the worker knows the total and the + bare current when it does not." + [{:keys [current total]}] + (when (some? current) + (if (some? total) + (str current "/" total) + (str current)))) + +(defn- upload-label + "The upload of a file counts chunks, not units of work, so it is written + as the percentage of the file already sent instead of a counter." + [{:keys [current total]}] + (when (and (some? current) (some? total) (pos? total)) + (str "(" (js/Math.round (* 100 (/ current total))) "%)"))) + +(defn milestone-text + "The text of a milestone: the stage in course and its counter, and with + `:file?` the file in course behind it, when the milestone carries its + counter. The upload stage is the exception: it counts chunks, so it is + written as a percentage of the file." + [milestone & {:keys [file?]}] + (when-some [stage (:stage milestone)] + (let [counters (:counters milestone) + own (if (= :upload stage) + (upload-label (get counters :upload)) + (counter-label (get counters stage)))] + (str (stage-label stage) + (when (some? own) + (str " " own)) + (when-let [files (and file? + (not= :files stage) + (counter-label (get counters :files)))] + (str " · " (stage-label :files) " " files)))))) diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index da6f0c8a95..860abcff27 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -12,7 +12,6 @@ [app.common.types.objects-map] [app.util.object :as obj] [app.worker.impl :as impl] - [app.worker.import] [app.worker.index] [app.worker.messages :as wm] [app.worker.thumbnails] diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs deleted file mode 100644 index e8310efa89..0000000000 --- a/frontend/src/app/worker/import.cljs +++ /dev/null @@ -1,253 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS SUBSIDIARY SL - -(ns app.worker.import - (:refer-clojure :exclude [resolve]) - (:require - [app.common.exceptions :as ex] - [app.common.json :as json] - [app.common.logging :as log] - [app.common.schema :as sm] - [app.common.uuid :as uuid] - [app.main.data.uploads :as uploads] - [app.main.repo :as rp] - [app.util.http :as http] - [app.util.i18n :as i18n :refer [tr]] - [app.util.sse :as sse] - [app.util.zip :as uz] - [app.worker.impl :as impl] - [beicon.v2.core :as rx] - [cuerdas.core :as str])) - -(log/set-level! :warn) - -(defn- import-cause-message - "Prefer the server `:hint` (full text, e.g. SSE error payload), then `:explain` - when present; avoid the generic `stream exception` wrapper when a payload exists." - [cause default-msg] - (let [data (ex-data cause) - hint (some-> data :hint str/trim) - explain (some-> data :explain str/trim)] - (cond - (not (str/blank? hint)) hint - (not (str/blank? explain)) explain - :else - (let [msg (some-> (ex-message cause) str/trim)] - (if (or (str/blank? msg) (= msg "stream exception")) - default-msg - msg))))) - -;; Upload changes batches size -(def ^:const change-batch-size 100) - -(def conjv (fnil conj [])) - -(defn read-zip-manifest - [zip-reader] - (->> (rx/from (uz/get-entry zip-reader "manifest.json")) - (rx/mapcat (fn [entry] - (if (nil? entry) - (rx/throw (ex/error :type :validation - :code :invalid-penpot-file - :hint "Not a valid Penpot file: manifest.json is missing")) - (uz/read-as-text entry)))) - (rx/map json/decode))) - -(defn slurp-uri - ([uri] (slurp-uri uri :text)) - ([uri response-type] - (->> (http/send! - {:uri uri - :response-type response-type - :method :get}) - (rx/map :body)))) - -(defn parse-mtype [ba] - (let [u8 (js/Uint8Array. ba 0 4) - sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))] - (case sg - "120 113 3 4" "application/zip" - "1 13 32 206" "application/octet-stream" - "other"))) - -;; NOTE: this is a limited subset schema for the manifest file of -;; binfile-v3 format; is used for partially parse it and read the -;; files referenced inside the exported file - -(def ^:private schema:manifest - [:map {:title "Manifest"} - [:type :string] - [:files - [:vector - [:map - [:id ::sm/uuid] - [:name :string]]]]]) - -(def ^:private decode-manifest - (sm/decoder schema:manifest sm/json-transformer)) - -(defn analyze-file - [{:keys [uri] :as file}] - (let [stream (->> (slurp-uri uri :buffer) - (rx/merge-map - (fn [body] - (let [mtype (parse-mtype body)] - (cond - (= "application/zip" mtype) - (let [zip-reader (uz/reader body)] - (->> (read-zip-manifest zip-reader) - (rx/map - (fn [manifest] - (if (= (:type manifest) "penpot/export-files") - (let [manifest (decode-manifest manifest)] - (assoc file :type :binfile-v3 :files (:files manifest))) - (assoc file :type :unknown)))) - (rx/finalize (partial uz/close zip-reader)))) - - (= "application/octet-stream" mtype) - (rx/of (assoc file :type :binfile-v1)) - - :else - (rx/of (assoc file :type :unknown)))))) - - (rx/share))] - - (->> (rx/merge - (->> stream - (rx/filter (fn [entry] (= :binfile-v1 (:type entry)))) - (rx/map (fn [entry] - (let [file-id (uuid/next)] - (-> entry - (assoc :file-id file-id) - (assoc :name (:name file)) - (assoc :status :success)))))) - - (->> stream - (rx/filter (fn [entry] (= :binfile-v3 (:type entry)))) - (rx/merge-map (fn [{:keys [files] :as entry}] - (->> (rx/from files) - (rx/map (fn [file] - (-> entry - (dissoc :files) - (assoc :name (:name file)) - (assoc :file-id (:id file)) - (assoc :status :success)))))))) - - (->> stream - (rx/filter (fn [data] (= :unknown (:type data)))) - (rx/map (fn [_] - {:uri (:uri file) - :status :error - :error (tr "dashboard.import.analyze-error")})))) - - (rx/catch (fn [cause] - (let [error (import-cause-message cause (tr "dashboard.import.analyze-error"))] - (rx/of (assoc file :error error :status :error)))))))) - -(defmethod impl/handler :analyze-import - [{:keys [files]}] - (->> (rx/from files) - (rx/merge-map analyze-file))) - -(defn- import-blob-via-upload - "Fetches `uri` as a Blob, uploads it using the generic chunked-upload - session API and calls `import-binfile` with the resulting upload-id. - Returns an observable of SSE events from the import stream." - [uri {:keys [name version project-id]}] - (->> (slurp-uri uri :blob) - (rx/mapcat - (fn [blob] - (->> (uploads/upload-blob-chunked blob) - (rx/mapcat - (fn [{:keys [session-id]}] - (rp/cmd! ::sse/import-binfile - {:name name - :upload-id session-id - :version version - :project-id project-id})))))))) - -(defmethod impl/handler :import-files - [{:keys [project-id files]}] - (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) - binfile-v3 (filter #(= :binfile-v3 (:type %)) files) - resolutions (volatile! {})] - - (rx/merge - (->> (rx/from binfile-v1) - (rx/merge-map - (fn [data] - (->> (import-blob-via-upload (:uri data) - {:name (str/replace (:name data) #".penpot$" "") - :version 1 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/map (fn [_] - {:status :finish - :file-id (:file-id data)})) - (rx/catch - (fn [cause] - (log/error :hint "unexpected error on import process" - :project-id project-id - :cause cause) - (rx/of {:status :error - :error (import-cause-message cause (tr "labels.error")) - :file-id (:file-id data)}))))))) - - - (rx/concat - (->> (rx/from binfile-v3) - (rx/reduce (fn [result file] - (update result (:uri file) (fnil conj []) file)) - {}) - (rx/mapcat identity) - (rx/merge-map - (fn [[uri entries]] - (->> (import-blob-via-upload uri - {:name (-> entries first :name) - :version 3 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (cond - (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - - :else - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/mapcat (fn [message] - (let [{:keys [resolution]} (sse/get-payload message)] - (when (seq resolution) - (vswap! resolutions merge resolution)) - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :finish - :file-id (:file-id entry)})))))) - (rx/catch (fn [cause] - (log/error :hint "import-binfile: unexpected error on importing" - :project-id project-id - ::log/sync? true - :cause cause) - (let [err (import-cause-message cause (tr "labels.error"))] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :error - :error err - :file-id (:file-id entry)})))))))))) - (->> (rx/defer #(rx/of @resolutions)) - (rx/map (fn [resolutions] - {:libraries-resolution resolutions}))))))) diff --git a/frontend/test/frontend_tests/data/exports_files_test.cljs b/frontend/test/frontend_tests/data/exports_files_test.cljs new file mode 100644 index 0000000000..a979ac34d7 --- /dev/null +++ b/frontend/test/frontend_tests/data/exports_files_test.cljs @@ -0,0 +1,286 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.exports-files-test + "Exporting files as jobs: one file, one job and one answer, with the + progress of the job reported while it runs." + (:require + [app.common.uuid :as uuid] + [app.main.data.exports.files :as fexp] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.websocket :as ws] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as hva] + [frontend-tests.helpers.mock :as mock])) + +(defn- file + [name] + {:id (uuid/next) + :name name + :project-id (uuid/next) + :is-shared false}) + +(defn- job + [job-id status & {:as extra}] + (merge {:id job-id :status status :name "export-binfile"} extra)) + +(defn- event + [job-id kind payload] + {:type :job-event + :job-id job-id + :kind kind + :payload payload}) + +(defn- message + [payload] + {:type :message :payload payload}) + +(defn- fake-server + "The backend of the export: the creation answers with a fresh job the + test drives, and the read answers the row of that job." + [calls rows] + (mock/stub + (fn [id params] + (case id + :create-export-binfile-job + (let [job-id (uuid/next)] + (swap! calls conj {:cmd id :params params :job-id job-id}) + (swap! rows assoc job-id (job job-id "running")) + (rx/of {:id job-id})) + + :get-job + (do + (swap! calls conj {:cmd id :params params}) + (rx/of (get @rows (:id params)))) + + (rx/of nil))))) + +(def ^:private created? + (comp #{:create-export-binfile-job} :cmd)) + +(defn- answered? + "True when the file already has its answer: the artifact or an error." + [results file-id] + (some (fn [result] + (and (= file-id (:file-id result)) + (or (:uri result) (:error result)))) + results)) + +(t/deftest ^:async a-file-is-exported-as-its-own-job + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + target (file "My file")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + rp/cmd! (fake-server calls rows) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (fexp/export-files :files [target] :type :detach-libraries) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (t/testing "the file is exported by a job of its own" + (t/is (= :create-export-binfile-job (:cmd (first @calls)))) + (t/is (= {:params {:file-ids #{(:id target)} + :export-type :detach-libraries}} + (:params (first @calls))))) + + (t/testing "the milestone of the job is reported while it runs" + (rx/push! ws-stream + (message (event job-id :progress + {:stage :pages + :counters {:files {:current 1 :total 1} + :pages {:current 1 :total 2}}}))) + (await (hva/wait-for #(some :progress @seen) "the milestone")) + + (t/is (= {:file-id (:id target) + :progress {:stage :pages + :counters {:files {:current 1 :total 1} + :pages {:current 1 :total 2}}}} + (last @seen)))) + + (t/testing "the artifact of the job answers when it is over" + (swap! rows assoc job-id + (job job-id "completed" + :result {:resource-uri "http://assets/export" + :filename "export.penpot" + :mtype "application/zip" + :size 120})) + (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) + (await done) + + (t/is (= {:file-id (:id target) + :uri "http://assets/export" + :filename "My file"} + (last @seen))))))))) + +(t/deftest ^:async a-failure-fails-only-its-own-file + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + one (file "First") + other (file "Second")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + rp/cmd! (fake-server calls rows) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (fexp/export-files :files [one other] :type :detach-libraries) + {:on-next #(swap! seen conj %)})] + + (t/testing "the first file answers with its artifact" + (let [job-id (:job-id (first (filter created? @calls)))] + (swap! rows assoc job-id + (job job-id "completed" :result {:resource-uri "http://assets/first"})) + (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) + (await (hva/wait-for #(answered? @seen (:id one)) "the first answer")))) + + (t/testing "and the second one fails on its own" + (await (hva/wait-for #(= 2 (count (filter created? @calls))) "the second job")) + (let [job-id (:job-id (second (filter created? @calls)))] + (swap! rows assoc job-id + (job job-id "failed" :error {:type :internal + :code :boom + :hint "boom"})) + (rx/push! ws-stream (message (event job-id :end {:outcome "failed"}))) + (await done))) + + (t/testing "the failure does not touch the file that worked" + (t/is (= {:file-id (:id one) + :uri "http://assets/first" + :filename "First"} + (first (filter #(and (= (:id one) (:file-id %)) + (:uri %)) + @seen))))) + + (t/is (= {:file-id (:id other) + :error {:type :internal :code :boom :hint "boom"}} + (last @seen)))))))) + +(t/deftest ^:async the-created-jobs-reach-the-caller-tagged-with-their-file + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen-jobs (atom []) + one (file "First") + other (file "Second")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + rp/cmd! (fake-server calls rows) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (fexp/export-files :files [one other] + :type :detach-libraries + :on-job #(swap! seen-jobs conj %)) + {:on-next (constantly nil)})] + + (await (hva/wait-for #(= 1 (count (filter created? @calls))) "the first job")) + + (t/testing "the server recorded the creation with its job id" + (let [creates (filterv created? @calls)] + (t/is (= 1 (count creates))) + (t/is (= :create-export-binfile-job (:cmd (first creates)))) + (t/is (uuid? (:job-id (first creates)))))) + + (t/testing "the caller learns that same job tagged with its file" + (t/is (= [{:job-id (:job-id (first (filterv created? @calls))) + :file-id (:id one)}] + @seen-jobs))) + + ;; files export one after the other: the second job is only + ;; created once the first one is over + (let [{job-1 :job-id} (first @seen-jobs)] + (swap! rows assoc job-1 (job job-1 "completed" :result {:resource-uri "http://assets/first"})) + (rx/push! ws-stream (message (event job-1 :end {:outcome "completed"})))) + + (await (hva/wait-for #(= 2 (count (filter created? @calls))) "the second job")) + + (t/testing "the caller learns the second job too, tagged and in order" + (t/is (= 2 (count @seen-jobs))) + (t/is (= (:job-id (second (filterv created? @calls))) (:job-id (second @seen-jobs)))) + (t/is (= (:id other) (:file-id (second @seen-jobs))))) + + (let [{job-2 :job-id} (second @seen-jobs)] + (swap! rows assoc job-2 (job job-2 "completed" :result {:resource-uri "http://assets/second"})) + (rx/push! ws-stream (message (event job-2 :end {:outcome "completed"}))) + (await done))))))) + +(t/deftest ^:async every-file-is-queued-until-its-first-milestone + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + one (file "First") + other (file "Second")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + rp/cmd! (fake-server calls rows) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (fexp/export-files :files [one other] :type :detach-libraries) + {:on-next #(swap! seen conj %)})] + + (t/testing "every file queues up front, before any of its jobs exists" + (await (hva/wait-for #(= 2 (count (filter :queued @seen))) "the queued messages")) + (t/is (= [{:file-id (:id one) :queued true} + {:file-id (:id other) :queued true}] + (vec (filter :queued @seen))))) + + (t/testing "a worker picking a job up is not a milestone and ends nothing" + (let [job-id (:job-id (first (filter created? @calls)))] + (rx/push! ws-stream (message (event job-id :start {:attempt 1}))) + (t/is (not-any? :started @seen)))) + + ;; finish the first job so the second one is created and the run ends + (let [job-1 (:job-id (first (filter created? @calls)))] + (swap! rows assoc job-1 + (job job-1 "completed" :result {:resource-uri "http://assets/first"})) + (rx/push! ws-stream (message (event job-1 :end {:outcome "completed"})))) + + (await (hva/wait-for #(= 2 (count (filter created? @calls))) "the second job")) + + (let [job-2 (:job-id (second (filter created? @calls)))] + (swap! rows assoc job-2 + (job job-2 "completed" :result {:resource-uri "http://assets/second"})) + (rx/push! ws-stream (message (event job-2 :end {:outcome "completed"}))) + (await done))))))) + +(t/deftest ^:async a-cancelled-job-marks-the-file-cancelled-without-artifact + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + target (file "My file")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + rp/cmd! (fake-server calls rows) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (fexp/export-files :files [target] :type :detach-libraries) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (t/testing "the cancelled outcome is an explicit message, never an artifact" + (swap! rows assoc job-id (job job-id "cancelled")) + (rx/push! ws-stream (message (event job-id :end {:outcome "cancelled"}))) + (await done) + (t/is (= {:file-id (:id target) :cancelled true} (last @seen))) + (t/is (not-any? :uri @seen)))))))) diff --git a/frontend/test/frontend_tests/data/imports_test.cljs b/frontend/test/frontend_tests/data/imports_test.cljs new file mode 100644 index 0000000000..e73802fe38 --- /dev/null +++ b/frontend/test/frontend_tests/data/imports_test.cljs @@ -0,0 +1,533 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.imports-test + "Reading a penpot package and importing it as a job." + (:require + [app.common.json :as json] + [app.common.uuid :as uuid] + [app.main.data.imports :as imp] + [app.main.data.jobs :as dj] + [app.main.data.uploads :as uploads] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.http :as http] + [app.util.i18n :refer [tr]] + [app.util.websocket :as ws] + [app.util.zip :as uz] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as hva] + [frontend-tests.helpers.mock :as mock] + [promesa.core :as p])) + +(def ^:private project-id + (uuid/next)) + +(def ^:private uri + "blob:package") + +(def ^:private zip-magic + (js/Uint8Array. #js [0x50 0x4b 0x03 0x04])) + +(defn- entry + "An entry of the wizard, as the analysis leaves it." + [& {:keys [id name type] + :or {type :binfile-v3}}] + {:file-id (or id (uuid/next)) + :uri uri + :name (or name "My file") + :type type}) + +(defn- job + [job-id status & {:as extra}] + (merge {:id job-id :status status :name "import-binfile"} extra)) + +(defn- event + [job-id kind payload] + {:type :job-event + :job-id job-id + :kind kind + :payload payload}) + +(defn- message + [payload] + {:type :message :payload payload}) + +(defn- slurp-stub + "The transport of a request that answers with `body`." + [body] + (mock/stub (fn [_] (rx/of {:body body})))) + +(defn- upload-stub + "The chunked upload of a package. It is variadic, so it is a plain fn." + [session-id] + (fn [_ & _] (rx/of {:session-id session-id}))) + +(defn- upload-with-progress-stub + "The chunked upload of a package that reports two chunks before + emitting the session, so the test sees the upload phase. It is + variadic, so it is a plain fn." + [session-id] + (fn [_ & {:keys [on-progress]}] + (when (fn? on-progress) + (on-progress {:current 1 :total 2}) + (on-progress {:current 2 :total 2})) + (rx/of {:session-id session-id}))) + +(defn- ws-stub + [stream] + (mock/stub (fn [_] stream))) + +(defn- fake-server + "The backend of the import: the creation answers with a fresh job the + test drives, and the read answers the row of that job." + [calls rows create-row] + (mock/stub + (fn [id params] + (case id + :create-import-binfile-job + (let [job-id (uuid/next)] + (swap! calls conj {:cmd id :params params :job-id job-id}) + (swap! rows assoc job-id (create-row job-id)) + (rx/of {:id job-id})) + + :get-job + (do + (swap! calls conj {:cmd id :params params}) + (rx/of (get @rows (:id params)))) + + (rx/of nil))))) + +(defn- manifest + [& files] + (json/encode {:type "penpot/export-files" + :files (mapv (fn [[id name]] {:id id :name name}) files)})) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; READING A PACKAGE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest ^:async a-package-without-a-manifest-is-rejected + (let [seen (atom [])] + + (await + (mock/with-mocks* + {http/send! (slurp-stub zip-magic) + uz/get-entry (mock/stub (fn [_ _] (p/resolved nil))) + uz/close (mock/stub (fn [_] nil))} + + (await (hva/observe (imp/analyze [{:uri uri :name "broken.penpot"}]) + {:on-next #(swap! seen conj %)})) + + (t/testing "the entry carries the error of the reader" + (t/is (= 1 (count @seen))) + (t/is (= :error (:status (first @seen)))) + (t/is (string? (:error (first @seen))))))))) + +(t/deftest ^:async a-version-3-package-answers-one-entry-per-file + (let [first-id (uuid/next) + second-id (uuid/next) + seen (atom [])] + + (await + (mock/with-mocks* + {http/send! (slurp-stub zip-magic) + uz/get-entry (mock/stub (fn [_ _] (p/resolved ::entry))) + uz/read-as-text (mock/stub (fn [_] (p/resolved (manifest [first-id "First"] + [second-id "Second"])))) + uz/close (mock/stub (fn [_] nil))} + + (await (hva/observe (imp/analyze [{:uri uri :name "package.penpot"}]) + {:on-next #(swap! seen conj %)})) + + (t/testing "every file of the manifest is an entry of the wizard" + (t/is (= [first-id second-id] (mapv :file-id @seen))) + (t/is (= ["First" "Second"] (mapv :name @seen))) + (t/is (every? #(= :binfile-v3 (:type %)) @seen)) + (t/is (every? #(= :success (:status %)) @seen))) + + (t/testing "all of them share the upload of the file that was dropped" + (t/is (every? #(= uri (:uri %)) @seen))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMPORTING A PACKAGE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest ^:async a-package-is-imported-as-its-own-job + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + session-id (uuid/next) + resolution {:done [{:id (str (uuid/next)) :name "Published"}]} + one (entry :name "First") + other (entry :name "Second")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub session-id) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [one other]}) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (t/testing "the package is uploaded and imported as one job" + (t/is (= :create-import-binfile-job (:cmd (first @calls)))) + (t/is (= {:project-id project-id :name "First" :version 3} + (:params (:params (first @calls))))) + (t/is (= session-id (:upload-id (:params (first @calls)))))) + + (t/testing "the milestone of the job reaches every entry of the package" + (rx/push! ws-stream + (message (event job-id :progress + {:stage :pages + :counters {:files {:current 1 :total 2} + :pages {:current 1 :total 8}}}))) + (await (hva/wait-for #(= 2 (count (filter (comp #{:progress} :status) @seen))) + "the milestone")) + + (t/is (= #{(:file-id one) (:file-id other)} + (into #{} (comp (filter (comp #{:progress} :status)) (map :file-id)) @seen))) + + (t/is (every? (fn [message] + (= {:stage :pages + :counters {:files {:current 1 :total 2} + :pages {:current 1 :total 8}}} + (:progress message))) + (filter (comp #{:progress} :status) @seen)))) + + (t/testing "the entries finish with the resolution of the package" + (swap! rows assoc job-id + (job job-id "completed" + :result {:file-ids (mapv (comp str :file-id) [one other]) + :resolution resolution + :name "First" + :version 3})) + (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) + (await done) + + (t/is (= #{(:file-id one) (:file-id other)} + (into #{} (comp (filter (comp #{:finish} :status)) (map :file-id)) @seen))) + + (t/is (= {:libraries-resolution resolution} (last @seen))))))))) + +(t/deftest ^:async the-job-reports-queued-then-started-before-progress + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + session-id (uuid/next) + one (entry :name "First") + other (entry :name "Second")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub session-id) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [one other]}) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (t/testing "every entry is queued while the job waits for a worker" + (await (hva/wait-for #(= 2 (count (filter (comp #{:queued} :status) @seen))) + "the queued messages")) + (t/is (= #{(:file-id one) (:file-id other)} + (into #{} (comp (filter (comp #{:queued} :status)) (map :file-id)) @seen)))) + + (t/testing "a started job moves the entries to progress without a milestone" + (rx/push! ws-stream (message (event job-id :start {:attempt 1}))) + (await (hva/wait-for #(= 2 (count (filter (comp #{:started} :status) @seen))) + "the started messages")) + (t/is (= #{(:file-id one) (:file-id other)} + (into #{} (comp (filter (comp #{:started} :status)) (map :file-id)) @seen)))) + + (t/testing "the milestone still arrives on top" + (rx/push! ws-stream + (message (event job-id :progress + {:stage :pages + :counters {:pages {:current 1 :total 8}}}))) + (await (hva/wait-for #(some :progress (filter (comp #{:progress} :status) @seen)) + "the milestone")) + (swap! rows assoc job-id + (job job-id "completed" + :result {:file-ids (mapv (comp str :file-id) [one other]) + :resolution {} + :name "First" + :version 3})) + (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) + (await done))))))) + +(t/deftest ^:async a-failed-job-fails-its-entries + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub (uuid/next)) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [target]}) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (swap! rows assoc job-id + (job job-id "failed" :error {:type :internal + :code :boom + :hint "The package is broken"})) + (rx/push! ws-stream (message (event job-id :end {:outcome "failed"}))) + (await done) + + (t/testing "the entry fails with the message of the job" + (t/is (= [{:status :queued + :file-id (:file-id target)} + {:status :error + :file-id (:file-id target) + :error "The package is broken"} + {:libraries-resolution {}}] + @seen)))))))) + +(t/deftest ^:async a-package-that-cannot-be-created-fails-its-entries + (let [ws-stream (rx/subject) + seen (atom []) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub (uuid/next)) + rp/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "cannot create the job" + {:type :validation + :code :boom + :hint "Not now"})))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (await (hva/observe (imp/import-files {:project-id project-id + :entries [target]}) + {:on-next #(swap! seen conj %)})) + + (t/testing "the failure reaches every entry of the package" + (t/is (= [{:status :error + :file-id (:file-id target) + :error "Not now"} + {:libraries-resolution {}}] + @seen))))))) + +(t/deftest ^:async a-job-that-already-ended-does-not-leave-the-entries-spinning + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub (uuid/next)) + ;; the job is over before the client reads it for the first time + rp/cmd! (fake-server calls rows + (fn [job-id] (job job-id "completed" :result {:resolution {}}))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (await (hva/observe (imp/import-files {:project-id project-id + :entries [target]}) + {:on-next #(swap! seen conj %)})) + + (t/testing "the entry finishes without waiting for an event" + (t/is (= [{:status :queued + :file-id (:file-id target)} + {:status :finish + :file-id (:file-id target)} + {:libraries-resolution {}}] + @seen))))))) + +(t/deftest ^:async the-upload-reports-chunks-before-the-job-runs + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + session-id (uuid/next) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-with-progress-stub session-id) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [target]}) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (await (hva/wait-for #(>= (count (filter (comp #{:progress} :status) @seen)) 2) + "the upload progress")) + + (t/testing "every uploaded chunk reaches the entry as an upload milestone" + (t/is (= [{:status :progress + :file-id (:file-id target) + :progress {:stage :upload + :counters {:upload {:current 1 :total 2}}}} + {:status :progress + :file-id (:file-id target) + :progress {:stage :upload + :counters {:upload {:current 2 :total 2}}}}] + (filterv (comp #{:progress} :status) @seen)))) + + (t/testing "the entry still finishes with the job" + (swap! rows assoc job-id (job job-id "completed" :result {:resolution {}})) + (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) + (await done) + + (t/is (= {:libraries-resolution {}} (last @seen))) + (t/is (= :finish (:status (nth @seen 3)))))))))) + +(t/deftest ^:async a-cancelled-job-closes-its-entries-as-cancelled + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub (uuid/next)) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [target]}) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + (t/testing "the cancelled ending terminates the stream" + (swap! rows assoc job-id (job job-id "cancelled")) + (rx/push! ws-stream (message (event job-id :end {:outcome "cancelled"}))) + (await done) + + (t/testing "the cancelled ending closes the entries and terminates the stream" + (t/is (= [{:status :queued + :file-id (:file-id target)} + {:status :error + :file-id (:file-id target) + :error (tr "jobs.import-cancelled")} + {:libraries-resolution {}}] + @seen))))))))) + +(t/deftest ^:async a-cancelled-job-closes-in-progress-entries-as-cancelled + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen (atom []) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub (uuid/next)) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [target]}) + {:on-next #(swap! seen conj %)}) + job-id (:job-id (first @calls))] + + ;; drive the entries to progress first, so the cancel + ;; interrupts a run in course instead of a queued job + (rx/push! ws-stream (message (event job-id :progress {:stage :manifest}))) + (await (hva/wait-for #(some (fn [message] (= :progress (:status message))) @seen) + "the progress")) + + (t/testing "an external cancel closes every entry as cancelled" + (swap! rows assoc job-id (job job-id "cancelled")) + (rx/push! ws-stream (message (event job-id :end {:outcome "cancelled"}))) + (await done) + (t/is (= [{:status :queued + :file-id (:file-id target)} + {:status :progress + :file-id (:file-id target) + :progress {:stage :manifest}} + {:status :error + :file-id (:file-id target) + :error (tr "jobs.import-cancelled")} + {:libraries-resolution {}}] + @seen)))))))) + +(t/deftest ^:async the-created-job-id-reaches-the-caller + (let [ws-stream (rx/subject) + calls (atom []) + rows (atom {}) + seen-jobs (atom []) + target (entry :name "Only")] + + (await + (mock/with-mocks* + {st/state (atom {:ws-conn ws-stream}) + http/send! (slurp-stub ::blob) + uploads/upload-blob-chunked (upload-stub (uuid/next)) + rp/cmd! (fake-server calls rows (fn [job-id] (job job-id "running"))) + ws/get-rcv-stream (ws-stub ws-stream)} + + (let [done (hva/observe (imp/import-files {:project-id project-id + :entries [target] + :on-job #(swap! seen-jobs conj %)}) + {:on-next (constantly nil)}) + job-id (:job-id (first @calls))] + + (t/testing "the caller learns the id of the package job" + (t/is (= [job-id] @seen-jobs))) + + (swap! rows assoc job-id (job job-id "completed" :result {:resolution {}})) + (rx/push! ws-stream (message (event job-id :end {:outcome "completed"}))) + (await done)))))) + +(t/deftest ^:async cancel-job-asks-the-server-and-ignores-failures + (let [calls (atom []) + job-id (uuid/next)] + + (t/testing "the cancel command reaches the server" + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [id params] + (swap! calls conj {:cmd id :params params}) + (rx/of {:id job-id :status "cancelled"})))} + (dj/cancel-job job-id) + (await (hva/settle)) + (t/is (= [{:cmd :cancel-job :params {:id job-id}}] @calls))))) + + (t/testing "a failure is swallowed: the job may have just finished" + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "gone" {}))))} + (dj/cancel-job job-id) + (await (hva/settle))))))) diff --git a/frontend/test/frontend_tests/data/jobs_test.cljs b/frontend/test/frontend_tests/data/jobs_test.cljs new file mode 100644 index 0000000000..996d9d1f0b --- /dev/null +++ b/frontend/test/frontend_tests/data/jobs_test.cljs @@ -0,0 +1,176 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.jobs-test + "Following a job: the progress the websocket publishes and the outcome + the row holds." + (:require + [app.common.uuid :as uuid] + [app.main.data.jobs :as dj] + [app.main.repo :as rp] + [app.util.http :as http] + [app.util.websocket :as ws] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [frontend-tests.helpers.async :as hva] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private job-id + (uuid/next)) + +(defn- job + "The row of a job, as `get-job` answers it." + [status & {:as extra}] + (merge {:id job-id :status status :name "export-binfile"} extra)) + +(defn- event + ([kind payload] (event job-id kind payload)) + ([id kind payload] + {:type :job-event + :job-id id + :kind kind + :payload payload})) + +(defn- message + [payload] + {:type :message :payload payload}) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FOLLOWING A JOB +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest ^:async progress-events-are-emitted-and-the-outcome-ends-the-stream + (let [ws-stream (rx/subject) + row (atom (job "running")) + seen (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of @row))) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (dj/watch-job nil job-id) + {:on-next #(swap! seen conj %)})] + + (rx/push! ws-stream + (message (event :progress {:stage :pages + :counters {:pages {:current 1 :total 2}}}))) + + (t/testing "an event of another job is not part of this stream" + (rx/push! ws-stream + (message (event (uuid/next) :progress {:stage :media}))) + (await (hva/settle)) + (t/is (= [{:kind :progress + :payload {:stage :pages + :counters {:pages {:current 1 :total 2}}}}] + @seen))) + + (t/testing "the closing event makes the stream read the row and end" + (reset! row (job "completed" :result {:resource-uri "uri"})) + (rx/push! ws-stream (message (event :end {:outcome "completed"}))) + (await done) + + (t/is (= [{:kind :progress + :payload {:stage :pages + :counters {:pages {:current 1 :total 2}}}} + {:status "completed" :result {:resource-uri "uri"}}] + @seen)))))))) + +(t/deftest ^:async a-job-that-already-ended-needs-no-event + (let [ws-stream (rx/subject) + seen (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] + (rx/of (job "completed" :result {:resource-uri "uri"})))) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (dj/watch-job nil job-id) + {:on-next #(swap! seen conj %)})] + (await done) + + (t/testing "the first read of the row is the outcome" + (t/is (= [{:status "completed" :result {:resource-uri "uri"}}] + @seen)))))))) + +(t/deftest ^:async a-reconnect-reads-the-row-again + (let [ws-stream (rx/subject) + row (atom (job "running")) + seen (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of @row))) + ws/get-rcv-stream (mock/stub (fn [_] ws-stream))} + + (let [done (hva/observe (dj/watch-job nil job-id) + {:on-next #(swap! seen conj %)})] + (await (hva/settle)) + + (t/testing "a job that is still running reports nothing" + (t/is (= [] @seen))) + + (t/testing "a reconnect answers for a job that failed while away" + (reset! row (job "failed" :error {:type :internal + :code :boom + :hint "boom"})) + (rx/push! ws-stream {:type :opened}) + (await done) + + (t/is (= [{:status "failed" + :error {:type :internal :code :boom :hint "boom"}}] + @seen)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; THE COMMANDS THAT FEED IT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- capture-requests! + [requests] + (fn [request] + (swap! requests conj request) + (rx/of {:status 200 + :uri (:uri request) + :headers {"content-type" "application/json"} + :body "{}"}))) + +(t/deftest ^:async the-commands-point-at-the-jobs-api + (let [requests (atom [])] + + (await + (mock/with-mocks* + {http/fetch (mock/stub (capture-requests! requests)) + http/response->map (mock/stub identity) + http/process-response-type (mock/stub (fn [_ response] (rx/of response)))} + + (await (hva/->promise (rp/cmd! :create-export-binfile-job + {:file-ids #{job-id} :export-type :detach-libraries}))) + (await (hva/->promise (rp/cmd! :create-import-binfile-job {:project-id (str job-id)}))) + (await (hva/->promise (rp/cmd! :get-job {:id (str job-id)}))) + (await (hva/->promise (rp/cmd! :create-export-job {}))) + + (let [[export import read exporter] @requests] + (t/testing "the binfile export asks the backend, not the exporter service" + (t/is (= :post (:method export))) + (t/is (str/ends-with? (:path (:uri export)) + "/api/main/methods/create-export-binfile-job"))) + + (t/testing "the import job needs no override: its name is the method" + (t/is (= :post (:method import))) + (t/is (str/ends-with? (:path (:uri import)) + "/api/main/methods/create-import-binfile-job"))) + + (t/testing "the read is a get with the id of the job" + (t/is (= :get (:method read))) + (t/is (str/ends-with? (:path (:uri read)) + "/api/main/methods/get-job")) + (t/is (= (str job-id) (:id (:query read))))) + + (t/testing "and the exporter keeps its own name" + (t/is (= :post (:method exporter))) + (t/is (str/ends-with? (:path (:uri exporter)) "/api/export/jobs")))))))) diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs index 0a7806b5cc..5dcec770e4 100644 --- a/frontend/test/frontend_tests/data/uploads_test.cljs +++ b/frontend/test/frontend_tests/data/uploads_test.cljs @@ -13,6 +13,7 @@ [app.main.data.uploads :as uploads] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as hva] [frontend-tests.helpers.http :as http])) ;; --------------------------------------------------------------------------- @@ -115,3 +116,42 @@ (http/restore-fetch! orig) (t/is (= 3 @chunk-calls)) (done)))))))) + +(t/deftest ^:async upload-blob-chunked-reports-progress-per-chunk + (let [session-id (uuid/next) + ;; Three chunks of 10 bytes: 2 full + 1 partial + blob (make-blob 25) + seen (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (http/make-transit-response + {:session-id session-id :index 0}) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (await + (hva/observe (uploads/upload-blob-chunked blob + :chunk-size 10 + :on-progress #(swap! seen conj %)) + {:on-next (fn [{:keys [session-id]}] + (t/is (uuid? session-id)))})) + (http/restore-fetch! orig) + + (t/testing "every uploaded chunk is reported with uploaded/total" + ;; chunks upload one at a time, so they are reported in order + (t/is (= [{:current 1 :total 3} + {:current 2 :total 3} + {:current 3 :total 3}] + @seen))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 501973b6e8..a12c63a205 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -12,6 +12,9 @@ [frontend-tests.data.dashboard-test] [frontend-tests.data.event-test] [frontend-tests.data.exports-assets-test] + [frontend-tests.data.exports-files-test] + [frontend-tests.data.imports-test] + [frontend-tests.data.jobs-test] [frontend-tests.data.nitrate-test] [frontend-tests.data.persistence-retry-test] [frontend-tests.data.persistence-test] @@ -112,6 +115,7 @@ [frontend-tests.ui.history-test] [frontend-tests.ui.inspect-geometry-test] [frontend-tests.ui.inspect-stroke-tokens-test] + [frontend-tests.ui.jobs-progress-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measurements-test] [frontend-tests.ui.measures-menu-props-test] @@ -162,6 +166,9 @@ 'frontend-tests.data.repo-test 'frontend-tests.data.store-test 'frontend-tests.data.exports-assets-test + 'frontend-tests.data.exports-files-test + 'frontend-tests.data.imports-test + 'frontend-tests.data.jobs-test 'frontend-tests.data.svg-upload-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test @@ -254,6 +261,7 @@ 'frontend-tests.ui.history-test 'frontend-tests.ui.inspect-geometry-test 'frontend-tests.ui.inspect-stroke-tokens-test + 'frontend-tests.ui.jobs-progress-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measurements-test 'frontend-tests.ui.measures-menu-props-test diff --git a/frontend/test/frontend_tests/ui/jobs_progress_test.cljs b/frontend/test/frontend_tests/ui/jobs_progress_test.cljs new file mode 100644 index 0000000000..9d25f852af --- /dev/null +++ b/frontend/test/frontend_tests/ui/jobs_progress_test.cljs @@ -0,0 +1,44 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.jobs-progress-test + "How a job milestone is written: the stage and its counter, and the + upload stage as a percentage of the file." + (:require + [app.main.ui.jobs.progress :as jp] + [app.util.i18n :as i18n] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private stub-tr + (mock/stub (fn [k] (str "TR:" k)))) + +(t/deftest a-counter-milestone-writes-its-stage-and-counter + (with-redefs [i18n/tr stub-tr] + (t/is (= "TR:jobs.progress.stage.pages 3/8" + (jp/milestone-text {:stage :pages :counters {:pages {:current 3 :total 8}}}))) + (t/testing "a counter with no total writes the bare current" + (t/is (= "TR:jobs.progress.stage.pages 3" + (jp/milestone-text {:stage :pages :counters {:pages {:current 3}}})))))) + +(t/deftest a-file-milestone-appends-the-file-counter + (with-redefs [i18n/tr stub-tr] + (t/is (= "TR:jobs.progress.stage.pages 3/8 · TR:jobs.progress.stage.files 1/2" + (jp/milestone-text {:stage :pages + :counters {:pages {:current 3 :total 8} + :files {:current 1 :total 2}}} + :file? true))))) + +(t/deftest an-upload-milestone-writes-the-percentage-of-the-file + (with-redefs [i18n/tr stub-tr] + (t/testing "the chunks sent become a whole percentage" + (t/is (= "TR:jobs.progress.stage.upload (33%)" + (jp/milestone-text {:stage :upload :counters {:upload {:current 1 :total 3}}}))) + (t/is (= "TR:jobs.progress.stage.upload (100%)" + (jp/milestone-text {:stage :upload :counters {:upload {:current 9 :total 9}}})))) + (t/testing "an upload with no total keeps the bare label" + (t/is (= "TR:jobs.progress.stage.upload" + (jp/milestone-text {:stage :upload :counters {:upload {:current 1}}})))))) diff --git a/frontend/test/frontend_tests/util_zip_test.cljs b/frontend/test/frontend_tests/util_zip_test.cljs index a9fbc658f7..6621335a8d 100644 --- a/frontend/test/frontend_tests/util_zip_test.cljs +++ b/frontend/test/frontend_tests/util_zip_test.cljs @@ -7,38 +7,9 @@ (ns frontend-tests.util-zip-test (:require [app.util.zip :as-alias uz] - [app.worker.import :as worker.import] - [beicon.v2.core :as rx] - [cljs.test :as t :include-macros true] - [frontend-tests.helpers.mock :as mock] - [promesa.core :as p])) + [cljs.test :as t :include-macros true])) (t/deftest read-as-text-nil-entry-raises-typed-error (t/testing "read-as-text guards against nil entry" (t/is (thrown-with-msg? js/Error #"nil" (app.util.zip/read-as-text nil))))) - -(t/deftest read-zip-manifest-missing-throws-validation-error - (t/async - done - (t/testing "read-zip-manifest rejects ZIPs without manifest.json" - (mock/with-mocks - {app.util.zip/get-entry (mock/stub (fn [_ _] (p/resolved nil)))} - (fn [done'] - (->> (worker.import/read-zip-manifest #js {}) - (rx/subs! - (fn [_] - (t/is false "expected validation error to be thrown") - (done')) - (fn [err] - (let [data (ex-data err)] - (t/is (= :invalid-penpot-file (:code data)) - "missing manifest.json raises typed :invalid-penpot-file error") - (t/is (string? (:hint data)) - "missing manifest.json error carries a :hint") - (t/is (re-find #"manifest\.json" (:hint data)) - "missing manifest.json :hint mentions manifest.json") - (t/is (nil? (re-find #"getData" (:hint data))) - "missing manifest.json :hint does not leak the raw TypeError text") - (done')))))) - done)))) \ No newline at end of file diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c38fce16ae..d71621ab02 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -715,34 +715,6 @@ msgstr "Next file" msgid "dashboard.import.no-perms" msgstr "You don’t have permission to import to this team" -#: src/app/main/ui/dashboard/import.cljs:134 -msgid "dashboard.import.progress.process-colors" -msgstr "Processing colors" - -#: src/app/main/ui/dashboard/import.cljs:143, src/app/main/ui/dashboard/import.cljs:146 -msgid "dashboard.import.progress.process-components" -msgstr "Processing components" - -#: src/app/main/ui/dashboard/import.cljs:140 -msgid "dashboard.import.progress.process-media" -msgstr "Processing media" - -#: src/app/main/ui/dashboard/import.cljs:131 -msgid "dashboard.import.progress.process-page" -msgstr "Processing page: %s" - -#: src/app/main/ui/dashboard/import.cljs:137 -msgid "dashboard.import.progress.process-typographies" -msgstr "Processing typographies" - -#: src/app/main/ui/dashboard/import.cljs:125 -msgid "dashboard.import.progress.upload-data" -msgstr "Uploading data to server (%s/%s)" - -#: src/app/main/ui/dashboard/import.cljs:128 -msgid "dashboard.import.progress.upload-media" -msgstr "Uploading file: %s" - #: src/app/main/ui/dashboard/import.cljs:443 msgid "dashboard.import.resolve-libraries" msgstr "" @@ -2193,18 +2165,6 @@ msgstr "Size and position" msgid "inspect.attributes.stroke" msgstr "Stroke" -#, permanent, unused -msgid "inspect.attributes.stroke.alignment.center" -msgstr "Center" - -#, permanent, unused -msgid "inspect.attributes.stroke.alignment.inner" -msgstr "Inside" - -#, permanent, unused -msgid "inspect.attributes.stroke.alignment.outer" -msgstr "Outside" - #: src/app/main/ui/inspect/attributes/text.cljs:53, src/app/main/ui/inspect/attributes/text.cljs:158 msgid "inspect.attributes.typography" msgstr "Typography" @@ -2614,6 +2574,74 @@ msgstr "The token will expire on %s" msgid "integrations.token.will-not-expire" msgstr "The token has no expiration date" +#: src/app/main/ui/exports/files.cljs:111 +msgid "jobs.export-cancelled" +msgstr "Export cancelled" + +#: src/app/main/data/imports.cljs:259 +msgid "jobs.import-cancelled" +msgstr "Import cancelled" + +#: src/app/main/ui/jobs/progress.cljs:26 +msgid "jobs.progress.stage.colors" +msgstr "Processing the colors" + +#: src/app/main/ui/jobs/progress.cljs:25 +msgid "jobs.progress.stage.components" +msgstr "Processing the components" + +#: src/app/main/ui/jobs/progress.cljs:21 +msgid "jobs.progress.stage.files" +msgstr "Processing the files" + +#: src/app/main/ui/jobs/progress.cljs:32 +msgid "jobs.progress.stage.manifest" +msgstr "Reading the package" + +#: src/app/main/ui/jobs/progress.cljs:23 +msgid "jobs.progress.stage.media" +msgstr "Processing the media" + +#: src/app/main/ui/jobs/progress.cljs:31 +msgid "jobs.progress.stage.metadata" +msgstr "Reading the metadata" + +#: src/app/main/ui/jobs/progress.cljs:22 +msgid "jobs.progress.stage.pages" +msgstr "Processing the pages" + +#: src/app/main/ui/jobs/progress.cljs:30 +msgid "jobs.progress.stage.relations" +msgstr "Processing the library relations" + +#: src/app/main/ui/jobs/progress.cljs:29 +msgid "jobs.progress.stage.storage-objects" +msgstr "Processing the storage objects" + +#: src/app/main/ui/jobs/progress.cljs:24 +msgid "jobs.progress.stage.thumbnails" +msgstr "Processing the thumbnails" + +#: src/app/main/ui/jobs/progress.cljs:28 +msgid "jobs.progress.stage.tokens" +msgstr "Processing the tokens" + +#: src/app/main/ui/jobs/progress.cljs:27 +msgid "jobs.progress.stage.typographies" +msgstr "Processing the typographies" + +#: src/app/main/ui/jobs/progress.cljs:33 +msgid "jobs.progress.stage.unknown" +msgstr "Working" + +#: src/app/main/ui/jobs/progress.cljs:33 +msgid "jobs.progress.stage.upload" +msgstr "Uploading file…" + +#: src/app/main/ui/dashboard/import.cljs:328, src/app/main/ui/exports/files.cljs:103 +msgid "jobs.queued" +msgstr "Queued" + #: src/app/main/ui/dashboard/comments.cljs:94 msgid "label.mark-all-as-read" msgstr "Mark all as read" @@ -2844,10 +2872,6 @@ msgstr "Download %s" msgid "labels.download-simple" msgstr "Download" -#: src/app/main/ui/exports/files.cljs:217 -msgid "labels.downloading-file" -msgstr "Downloading file…" - #: src/app/main/ui/dashboard/file_menu.cljs:28, src/app/main/ui/dashboard/files.cljs:86, src/app/main/ui/dashboard/files.cljs:207, src/app/main/ui/dashboard/projects.cljs:225, src/app/main/ui/dashboard/projects.cljs:228, src/app/main/ui/dashboard/sidebar.cljs:362 msgid "labels.drafts" msgstr "Drafts" @@ -3439,10 +3463,6 @@ msgstr "Upload custom fonts" msgid "labels.uploading" msgstr "Uploading…" -#: src/app/main/ui/dashboard/import.cljs:648 -msgid "labels.uploading-file" -msgstr "Uploading file…" - #: src/app/main/ui/inspect/right_sidebar.cljs:65, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1034 msgid "labels.variant" msgstr "Variant" @@ -6251,10 +6271,6 @@ msgstr "Typographies" msgid "workspace.assets.typography.add-typography" msgstr "Add typography" -#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461 -msgid "workspace.assets.typography.multiple-texts-selected" -msgstr "Select one text to create a typography style" - #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:855 msgid "workspace.assets.typography.font-size" msgstr "Size" @@ -6275,6 +6291,10 @@ msgstr "Letter Spacing" msgid "workspace.assets.typography.line-height" msgstr "Line Height" +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461 +msgid "workspace.assets.typography.multiple-texts-selected" +msgstr "Select one text to create a typography style" + #: src/app/main/ui/dashboard/grid.cljs:226, src/app/main/ui/workspace/libraries.cljs:1070, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:802, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:838, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:957, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:976 msgid "workspace.assets.typography.sample" msgstr "Ag" @@ -9214,10 +9234,6 @@ msgstr "Line (%s)" msgid "workspace.toolbar.mcp" msgstr "MCP" -#: src/app/main/ui/workspace/top_toolbar.cljs:345 -msgid "workspace.toolbar.mcp-connect-here" -msgstr "Connect here" - #: src/app/main/ui/workspace/top_toolbar.cljs:342 msgid "workspace.toolbar.mcp-connected" msgstr "MCP connected" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 00ea23e0b5..c1464fe980 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -726,34 +726,6 @@ msgstr "Siguiente archivo" msgid "dashboard.import.no-perms" msgstr "No tienes permisos para importar en este equipo" -#: src/app/main/ui/dashboard/import.cljs:134 -msgid "dashboard.import.progress.process-colors" -msgstr "Procesando colores" - -#: src/app/main/ui/dashboard/import.cljs:143, src/app/main/ui/dashboard/import.cljs:146 -msgid "dashboard.import.progress.process-components" -msgstr "Procesando componentes" - -#: src/app/main/ui/dashboard/import.cljs:140 -msgid "dashboard.import.progress.process-media" -msgstr "Procesando media" - -#: src/app/main/ui/dashboard/import.cljs:131 -msgid "dashboard.import.progress.process-page" -msgstr "Procesando página: %s" - -#: src/app/main/ui/dashboard/import.cljs:137 -msgid "dashboard.import.progress.process-typographies" -msgstr "Procesando tipografías" - -#: src/app/main/ui/dashboard/import.cljs:125 -msgid "dashboard.import.progress.upload-data" -msgstr "Enviando datos al servidor (%s/%s)" - -#: src/app/main/ui/dashboard/import.cljs:128 -msgid "dashboard.import.progress.upload-media" -msgstr "Enviando fichero: %s" - #: src/app/main/ui/dashboard/import.cljs:443 msgid "dashboard.import.resolve-libraries" msgstr "" @@ -1542,6 +1514,12 @@ msgstr "" msgid "errors.comment-error" msgstr "Ha habido un error con el comentario" +#: src/app/main/errors.cljs:499 +msgid "errors.connection-error" +msgstr "" +"No se puede conectar con el servidor. Comprueba tu conexión e inténtalo de " +"nuevo." + #: src/app/main/errors.cljs:851 msgid "errors.deprecated" msgstr "" @@ -1782,6 +1760,10 @@ msgstr "" "informar del error y recarga para continuar desde los últimos cambios " "guardados." +#: src/app/main/data/persistence.cljs:307 +msgid "errors.save-retrying" +msgstr "Se ha perdido la conexión. Reintentando guardar los cambios." + #: src/app/main/errors.cljs:773 msgid "errors.svg-parser.invalid-svg" msgstr "El SVG no es válido o está mal formado" @@ -2224,18 +2206,6 @@ msgstr "Tamaño y posición" msgid "inspect.attributes.stroke" msgstr "Borde" -#, permanent, unused -msgid "inspect.attributes.stroke.alignment.center" -msgstr "Centro" - -#, permanent, unused -msgid "inspect.attributes.stroke.alignment.inner" -msgstr "Interior" - -#, permanent, unused -msgid "inspect.attributes.stroke.alignment.outer" -msgstr "Exterior" - #: src/app/main/ui/inspect/attributes/text.cljs:53, src/app/main/ui/inspect/attributes/text.cljs:158 msgid "inspect.attributes.typography" msgstr "Tipografía" @@ -2647,6 +2617,74 @@ msgstr "El token expirará el %s" msgid "integrations.token.will-not-expire" msgstr "El token no tiene fecha de expiración" +#: src/app/main/ui/exports/files.cljs:111 +msgid "jobs.export-cancelled" +msgstr "Exportación cancelada" + +#: src/app/main/data/imports.cljs:259 +msgid "jobs.import-cancelled" +msgstr "Importación cancelada" + +#: src/app/main/ui/jobs/progress.cljs:26 +msgid "jobs.progress.stage.colors" +msgstr "Procesando colores" + +#: src/app/main/ui/jobs/progress.cljs:25 +msgid "jobs.progress.stage.components" +msgstr "Procesando componentes" + +#: src/app/main/ui/jobs/progress.cljs:21 +msgid "jobs.progress.stage.files" +msgstr "Procesando archivos" + +#: src/app/main/ui/jobs/progress.cljs:32 +msgid "jobs.progress.stage.manifest" +msgstr "Leyendo el paquete" + +#: src/app/main/ui/jobs/progress.cljs:23 +msgid "jobs.progress.stage.media" +msgstr "Procesando media" + +#: src/app/main/ui/jobs/progress.cljs:31 +msgid "jobs.progress.stage.metadata" +msgstr "Leyendo metadatos" + +#: src/app/main/ui/jobs/progress.cljs:22 +msgid "jobs.progress.stage.pages" +msgstr "Procesando páginas" + +#: src/app/main/ui/jobs/progress.cljs:30 +msgid "jobs.progress.stage.relations" +msgstr "Procesando relaciones de biblioteca" + +#: src/app/main/ui/jobs/progress.cljs:29 +msgid "jobs.progress.stage.storage-objects" +msgstr "Procesando objetos de almacenamiento" + +#: src/app/main/ui/jobs/progress.cljs:24 +msgid "jobs.progress.stage.thumbnails" +msgstr "Procesando miniaturas" + +#: src/app/main/ui/jobs/progress.cljs:28 +msgid "jobs.progress.stage.tokens" +msgstr "Procesando tokens" + +#: src/app/main/ui/jobs/progress.cljs:27 +msgid "jobs.progress.stage.typographies" +msgstr "Procesando tipografías" + +#: src/app/main/ui/jobs/progress.cljs:33 +msgid "jobs.progress.stage.unknown" +msgstr "Trabajando" + +#: src/app/main/ui/jobs/progress.cljs:33 +msgid "jobs.progress.stage.upload" +msgstr "Subiendo archivo…" + +#: src/app/main/ui/dashboard/import.cljs:328, src/app/main/ui/exports/files.cljs:103 +msgid "jobs.queued" +msgstr "En cola" + #: src/app/main/ui/dashboard/comments.cljs:94 msgid "label.mark-all-as-read" msgstr "Marcar todo como leído" @@ -2877,10 +2915,6 @@ msgstr "Descargar %s" msgid "labels.download-simple" msgstr "Descargar" -#: src/app/main/ui/exports/files.cljs:217 -msgid "labels.downloading-file" -msgstr "Descargando archivo…" - #: src/app/main/ui/dashboard/file_menu.cljs:28, src/app/main/ui/dashboard/files.cljs:86, src/app/main/ui/dashboard/files.cljs:207, src/app/main/ui/dashboard/projects.cljs:225, src/app/main/ui/dashboard/projects.cljs:228, src/app/main/ui/dashboard/sidebar.cljs:362 msgid "labels.drafts" msgstr "Borradores" @@ -3476,10 +3510,6 @@ msgstr "Cargar fuente" msgid "labels.uploading" msgstr "Subiendo…" -#: src/app/main/ui/dashboard/import.cljs:648 -msgid "labels.uploading-file" -msgstr "Subiendo archivo…" - #: src/app/main/ui/inspect/right_sidebar.cljs:65, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1034 msgid "labels.variant" msgstr "Variante" @@ -4986,6 +5016,14 @@ msgstr "Cancelar" msgid "shortcuts.export-shapes" msgstr "Exportar elementos" +#: src/app/main/data/workspace/shortcuts.cljs:184 +msgid "shortcuts.find" +msgstr "Buscar" + +#: src/app/main/data/workspace/shortcuts.cljs:188 +msgid "shortcuts.find-and-replace" +msgstr "Buscar y reemplazar" + #: src/app/main/data/workspace/grid_layout/shortcuts.cljs:75, src/app/main/data/workspace/path/shortcuts.cljs:226, src/app/main/data/workspace/shortcuts.cljs:711 msgid "shortcuts.fit-all" msgstr "Zoom abarcar todo" @@ -5222,6 +5260,10 @@ msgstr "Pegar" msgid "shortcuts.paste-props" msgstr "Pegar propiedades" +#: src/app/main/data/workspace/shortcuts.cljs:125 +msgid "shortcuts.paste-replace" +msgstr "Pegar y reemplazar" + #: src/app/main/ui/settings/shortcuts.cljs:296, src/app/main/ui/settings/shortcuts.cljs:523 msgid "shortcuts.personalized" msgstr "Personalizados" @@ -6320,10 +6362,6 @@ msgstr "Tipografías" msgid "workspace.assets.typography.add-typography" msgstr "Añadir tipografía" -#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461 -msgid "workspace.assets.typography.multiple-texts-selected" -msgstr "Selecciona un solo texto para crear un estilo de tipografía" - #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:855 msgid "workspace.assets.typography.font-size" msgstr "Tamaño" @@ -6344,6 +6382,10 @@ msgstr "Interletrado" msgid "workspace.assets.typography.line-height" msgstr "Interlineado" +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:461 +msgid "workspace.assets.typography.multiple-texts-selected" +msgstr "Selecciona un solo texto para crear un estilo de tipografía" + #: src/app/main/ui/dashboard/grid.cljs:226, src/app/main/ui/workspace/libraries.cljs:1070, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:802, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:838, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:957, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:976 msgid "workspace.assets.typography.sample" msgstr "Ag" @@ -6670,6 +6712,10 @@ msgstr "Desbloquear guías" msgid "workspace.header.reset-zoom" msgstr "Restablecer" +#: src/app/main/ui/workspace/left_header.cljs:125 +msgid "workspace.header.retrying" +msgstr "Reintentando guardar" + #: src/app/main/ui/workspace/left_header.cljs:126 msgid "workspace.header.save-error" msgstr "Error al guardar" @@ -9161,6 +9207,10 @@ msgstr "Tamaño" msgid "workspace.tokens.source" msgstr "Fuente:" +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:311, src/app/main/ui/workspace/tokens/management/context_menu.cljs:335 +msgid "workspace.tokens.stroke-width" +msgstr "Ancho del trazo" + #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:54, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 msgid "workspace.tokens.text-case-value-enter" msgstr "none | uppercase | lowercase | capitalize o {alias}" @@ -9297,14 +9347,22 @@ msgstr "Línea (%s)" msgid "workspace.toolbar.mcp" msgstr "MCP" -#: src/app/main/ui/workspace/top_toolbar.cljs:345 -msgid "workspace.toolbar.mcp-connect-here" -msgstr "Conectar aquí" - #: src/app/main/ui/workspace/top_toolbar.cljs:342 msgid "workspace.toolbar.mcp-connected" msgstr "MCP conectado" +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-copy-session-id" +msgstr "Copiar ID de sesión" + +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-session-copied" +msgstr "ID de sesión copiado" + +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-session-id" +msgstr "ID de sesión: %s" + #: src/app/main/ui/workspace/top_toolbar.cljs:65, src/app/main/ui/workspace/top_toolbar.cljs:425 msgid "workspace.toolbar.move" msgstr "Mover (%s)" From cc5f2f57adb78aaad3fb2ef6ac0fcc0ebdd053d7 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 7 Oct 2026 12:08:33 +0200 Subject: [PATCH 046/325] :arrow_up: Upgrade skia-safe to 0.153.3 with wasm SIMD (#12148) Point SKIA_BINARIES_URL at the 0.153.3 simd prebuild and adapt fonts, path stroke expansion, and gradients to the new API. --- render-wasm/Cargo.lock | 67 ++++++++++++++++---------- render-wasm/Cargo.toml | 2 +- render-wasm/_build_env | 2 +- render-wasm/lint | 3 +- render-wasm/src/render.rs | 3 +- render-wasm/src/render/fonts.rs | 13 +++-- render-wasm/src/render/images.rs | 5 +- render-wasm/src/render/strokes.rs | 4 +- render-wasm/src/render/ui/rulers.rs | 14 +++--- render-wasm/src/shapes/fills.rs | 33 ++++++++----- render-wasm/src/shapes/stroke_paths.rs | 7 +-- render-wasm/src/shapes/text_paths.rs | 2 +- render-wasm/test | 2 +- render-wasm/watch_test | 2 +- 14 files changed, 96 insertions(+), 63 deletions(-) diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 05fbd20e6a..a5c139b930 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -23,6 +23,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bezier-rs" version = "0.4.0" @@ -49,7 +55,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.106", ] [[package]] @@ -334,7 +340,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -393,7 +399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "910d41a655dac3b764f1ade94821093d3610248694320cd072303a8eedcf221d" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.106", ] [[package]] @@ -425,9 +431,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -437,9 +443,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -448,15 +454,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "render" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "bezier-rs", "gl", "glam", @@ -525,7 +531,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -563,9 +569,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "skia-bindings" -version = "0.93.1" +version = "0.153.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2359f7e30c9da3f322f8ca3d4ec0abbc12a40035ce758309db0cdab07b5d4476" +checksum = "eb325787c91b3f6c34a7c6f2b8a2297b20292d733ca53a08c233e5028aec49af" dependencies = [ "bindgen", "cc", @@ -580,11 +586,11 @@ dependencies = [ [[package]] name = "skia-safe" -version = "0.93.1" +version = "0.153.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e837ea9d531c9efee8f980bfcdb7226b21db0285b0c3171d8be745829f940" +checksum = "c61cb643dac43067f3eb76c866635a74377ac0abca0ff2590674e1e295514c3d" dependencies = [ - "base64", + "base64 0.23.1", "bitflags", "percent-encoding", "skia-bindings", @@ -593,13 +599,13 @@ dependencies = [ [[package]] name = "skia-svg-macros" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "044dd2233c9717a74f75197f3e7f0a966db2127c0ffb5e05013b480a9b75b2c7" +checksum = "fec38aa7a389d249e1ce357b050ae5bdb302fd9943661fa650f39cf6ea51be97" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -614,10 +620,21 @@ dependencies = [ ] [[package]] -name = "tar" -version = "0.4.42" +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff6c40d3aedb5e06b57c6f669ad17ab063dd1e63d977c6a88e7f4dfa4f04020" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -654,7 +671,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -740,7 +757,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -762,7 +779,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 8c0be81049..7b2951a2a1 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -26,7 +26,7 @@ gl = "0.14.0" glam = "0.24.2" indexmap = "2.7.1" macros = { path = "macros" } -skia-safe = { version = "0.93.1", default-features = false, features = [ +skia-safe = { version = "0.153.3", default-features = false, features = [ "gl", "svg", "textlayout", diff --git a/render-wasm/_build_env b/render-wasm/_build_env index dc03bebd89..2673c81ba4 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -20,7 +20,7 @@ fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} # 256 MB of initial heap to perform less # initial calls to memory grow. diff --git a/render-wasm/lint b/render-wasm/lint index 0489e1040d..aeacf782c4 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -10,8 +10,7 @@ fi . ./_build_env frontend -export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} ALLOWED_RULES="-D static_mut_refs" diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 65f7665dec..44835b88f4 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -767,8 +767,9 @@ impl RenderState { paint.set_style(skia::PaintStyle::Stroke); paint.set_stroke_width(stroke_outset * 2.0); - let mut outline = skia::Path::default(); + let mut outline = skia::PathBuilder::new(); if skia::path_utils::fill_path_with_paint(&base, &paint, &mut outline, None, None) { + let outline = outline.detach(); if let Some(united) = base.op(&outline, skia::PathOp::Union) { return united; } diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 78ae886064..b1a09531cc 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -33,7 +33,12 @@ pub struct FontStore { impl FontStore { pub fn try_new() -> Result { - let font_mgr = FontMgr::new(); + // custom_empty: FreeType-backed manager with no system scan. FontMgr::new() + // calls SkFontMgr_NewSystem, which on wasm tries to opendir font paths and + // aborts when Emscripten is built with -sFILESYSTEM=0 (exporter + browser). + let font_mgr = FontMgr::custom_empty().ok_or(Error::CriticalError( + "Failed to create empty FontMgr".to_string(), + ))?; let font_provider = load_default_provider(&font_mgr); let mut font_collection = skia::textlayout::FontCollection::new(); font_collection.set_default_font_manager(FontMgr::from(font_provider.clone()), None); @@ -47,7 +52,7 @@ impl FontStore { let debug_font = skia::Font::new(debug_typeface, 12.0); let ui_typeface = font_mgr - .new_from_data(UI_FONT_BYTES, None) + .new_from_data(skia::Data::new_copy(UI_FONT_BYTES), None) .ok_or(Error::CriticalError("Failed to load UI font".to_string()))?; let ui_font = skia::Font::new(ui_typeface, 12.0); @@ -96,7 +101,7 @@ impl FontStore { let typeface = self .font_mgr - .new_from_data(font_data, None) + .new_from_data(skia::Data::new_copy(font_data), None) .ok_or(Error::CriticalError( "Failed to create typeface".to_string(), ))?; @@ -265,7 +270,7 @@ fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontPr let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal); let font = font_mgr - .new_from_data(DEFAULT_FONT_BYTES, None) + .new_from_data(skia::Data::new_copy(DEFAULT_FONT_BYTES), None) .expect("Failed to load font"); font_provider.register_typeface(font, family.alias().as_str()); diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 4b1ce66754..5b59b3620a 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -186,8 +186,9 @@ const DEFAULT_SVG_SIZE: f32 = 512.0; fn parse_svg(raw_data: &[u8]) -> Option<(skia::svg::Dom, Size)> { // An empty font manager: elements inside SVG image fills won't // resolve typefaces. Wire the render state's font provider here if that - // ever becomes a need. - let font_mgr = skia::FontMgr::new(); + // ever becomes a need. Must be custom_empty (not FontMgr::new): NewSystem + // scans font dirs and aborts under -sFILESYSTEM=0. + let font_mgr = skia::FontMgr::custom_empty()?; let mut dom = skia::svg::Dom::from_bytes(raw_data, font_mgr).ok()?; let mut size = dom.root().intrinsic_size(); diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 48efeada92..8a89015034 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -439,7 +439,7 @@ fn expand_side_patterns( line_pb.detach() }; - let mut outline = skia::Path::default(); + let mut outline = skia::PathBuilder::new(); if skia::path_utils::fill_path_with_paint( &line_path, &stamp_paint, @@ -447,7 +447,7 @@ fn expand_side_patterns( None, None, ) { - pb.add_path(&outline); + pb.add_path(&outline.detach(), None); expanded = true; } } diff --git a/render-wasm/src/render/ui/rulers.rs b/render-wasm/src/render/ui/rulers.rs index 16bf6f4acc..18e9d0a4e3 100644 --- a/render-wasm/src/render/ui/rulers.rs +++ b/render-wasm/src/render/ui/rulers.rs @@ -364,14 +364,12 @@ fn draw_mask(ctx: &RenderCtx, rect: Rect, axis: MaskAxis, fade_to_end: bool) { MaskAxis::Horizontal => (rect.right, rect.top), MaskAxis::Vertical => (rect.left, rect.bottom), }; - let shader = skia::gradient_shader::linear( - ((rect.left, rect.top), end), - &colors[..], - Some(&offsets[..]), - skia::TileMode::Clamp, - None, - None, - ); + let colors4f: Vec = colors.iter().map(|c| skia::Color4f::from(*c)).collect(); + let gradient_colors = + skia::gradient::Colors::new(&colors4f, Some(&offsets[..]), skia::TileMode::Clamp, None); + let gradient = + skia::gradient::Gradient::new(gradient_colors, skia::gradient::Interpolation::default()); + let shader = skia::shaders::linear_gradient(((rect.left, rect.top), end), &gradient, None); let mut paint = Paint::default(); paint.set_anti_alias(false); paint.set_style(PaintStyle::Fill); diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 1faeb54185..94d33e7dd2 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -74,14 +74,20 @@ impl Gradient { rect.left + self.end.0 * rect.width(), rect.top + self.end.1 * rect.height(), ); - skia::gradient_shader::linear( - (start, end), - self.colors.as_slice(), + let colors4f: Vec = self + .colors + .iter() + .map(|c| skia::Color4f::from(*c)) + .collect(); + let colors = skia::gradient::Colors::new( + &colors4f, Some(self.offsets.as_slice()), skia::TileMode::Clamp, None, - None, - ) + ); + let gradient = + skia::gradient::Gradient::new(colors, skia::gradient::Interpolation::default()); + skia::shaders::linear_gradient((start, end), &gradient, None) } pub fn to_radial_shader(&self, rect: &Rect) -> Option { @@ -106,15 +112,20 @@ impl Gradient { transform.pre_scale((self.width * rect.width() / rect.height(), 1.), None); transform.pre_translate((-center.x, -center.y)); - skia::gradient_shader::radial( - center, - distance, - self.colors.as_slice(), + let colors4f: Vec = self + .colors + .iter() + .map(|c| skia::Color4f::from(*c)) + .collect(); + let colors = skia::gradient::Colors::new( + &colors4f, Some(self.offsets.as_slice()), skia::TileMode::Clamp, None, - Some(&transform), - ) + ); + let gradient = + skia::gradient::Gradient::new(colors, skia::gradient::Interpolation::default()); + skia::shaders::radial_gradient((center, distance), &gradient, Some(&transform)) } } diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs index 52dc58e762..bcdbdbeac4 100644 --- a/render-wasm/src/shapes/stroke_paths.rs +++ b/render-wasm/src/shapes/stroke_paths.rs @@ -48,7 +48,7 @@ pub fn stroke_to_path( paint.set_stroke_cap(cap); } - let mut stroke_outline = skia::Path::default(); + let mut stroke_outline = skia::PathBuilder::new(); let success = skia::path_utils::fill_path_with_paint( &transformed_shape_path, &paint, @@ -60,6 +60,7 @@ pub fn stroke_to_path( if !success { return None; } + let stroke_outline = stroke_outline.detach(); // For inner/outer strokes, use boolean ops to clip // the 2×-width stroke outline to the correct region. @@ -198,9 +199,9 @@ fn stroke_region(path: &skia::Path, width: f32) -> Option { paint.set_style(skia::PaintStyle::Stroke); paint.set_stroke_width(width); - let mut outline = skia::Path::default(); + let mut outline = skia::PathBuilder::new(); skia::path_utils::fill_path_with_paint(path, &paint, &mut outline, None, None) - .then_some(outline) + .then(|| outline.detach()) } /// Filled geometry of a single stroke cap, matching what `handle_stroke_caps` diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 777181a3a4..44cc21a115 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -68,7 +68,7 @@ impl TextPaths { let Some(glyph_path) = font.get_path(*glyph) else { continue; }; - builder.add_path(&glyph_path.with_offset(run_origin + *position)); + builder.add_path(&glyph_path.with_offset(run_origin + *position), None); has_glyphs = true; } diff --git a/render-wasm/test b/render-wasm/test index a94cf8025d..c27d5ea533 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -x -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp-pdf.tar.gz"} export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"}; _SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) diff --git a/render-wasm/watch_test b/render-wasm/watch_test index 798eb84bf0..27106e49bd 100755 --- a/render-wasm/watch_test +++ b/render-wasm/watch_test @@ -1,7 +1,7 @@ #!/usr/bin/env bash _SCRIPT_DIR=$(dirname $0); -export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz" +export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp-pdf.tar.gz" pushd $_SCRIPT_DIR; cargo watch -x "test --bin render_wasm -- --show-output" From 1d7c775f7207f706346b6233e49be0f0e89449c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 7 Oct 2026 12:26:45 +0200 Subject: [PATCH 047/325] :bug: Fix not setting mimetype in image fills (#12136) --- common/src/app/common/types/fills/impl.cljc | 42 ++++++++++++------- frontend/src/app/render_wasm/text_editor.cljs | 3 ++ render-wasm/src/shapes/fills.rs | 12 ++++++ render-wasm/src/wasm/fills.rs | 3 +- render-wasm/src/wasm/fills/image.rs | 5 ++- 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 2f8ca65a6f..453b95d6b3 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -59,6 +59,27 @@ result (bit-or result n)] result)) +(defn encode-mtype + "Encode an image media type as the 16-bit code shared with render-wasm." + [mtype] + (case mtype + "image/jpeg" 0x01 + "image/png" 0x02 + "image/gif" 0x03 + "image/webp" 0x04 + "image/svg+xml" 0x05 + 0x00)) + +(defn decode-mtype + [code] + (case code + 0x01 "image/jpeg" + 0x02 "image/png" + 0x03 "image/gif" + 0x04 "image/webp" + 0x05 "image/svg+xml" + nil)) + (defn- get-color-hex [n] (let [n (bit-and n 0x00ffffff) @@ -127,12 +148,13 @@ transform (get image :transform) has-transform? (some? transform) transform-flag (if has-transform? 0x02 0x00) - flags (bit-or keep-aspect-ratio transform-flag)] + flags (bit-or keep-aspect-ratio transform-flag) + mtype (encode-mtype (get image :mtype))] (buf/write-byte buffer (+ offset 0) 0x03) (buf/write-uuid buffer (+ offset 4) image-id) (buf/write-byte buffer (+ offset 20) alpha) (buf/write-byte buffer (+ offset 21) flags) - (buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use) + (buf/write-short buffer (+ offset 22) mtype) (buf/write-int buffer (+ offset 24) image-width) (buf/write-int buffer (+ offset 28) image-height) (if has-transform? @@ -155,13 +177,7 @@ mtype (dm/get-in fill [:fill-image :mtype])] (when mtype - (let [val (case mtype - "image/jpeg" 0x01 - "image/png" 0x02 - "image/gif" 0x03 - "image/webp" 0x04 - "image/svg+xml" 0x05)] - (buf/write-short buffer (+ offset 2) val))) + (buf/write-short buffer (+ offset 2) (encode-mtype mtype))) (if (and (some? ref-file) (some? ref-id)) @@ -235,13 +251,7 @@ :y (buf/read-float dbuffer (+ doffset 36)) :width (buf/read-float dbuffer (+ doffset 40)) :height (buf/read-float dbuffer (+ doffset 44))}) - mtype (buf/read-short mbuffer (+ moffset 2)) - mtype (case mtype - 0x01 "image/jpeg" - 0x02 "image/png" - 0x03 "image/gif" - 0x04 "image/webp" - 0x05 "image/svg+xml")] + mtype (decode-mtype (buf/read-short mbuffer (+ moffset 2)))] {:fill-opacity opacity :fill-image (cond-> {:id id :width width diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index ed82300c09..6493128aba 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -118,12 +118,15 @@ d (aget heap-u32 (+ fill-u32-offset 4)) alpha (aget heap-u8 (+ fill-byte-offset 20)) flags (aget heap-u8 (+ fill-byte-offset 21)) + mtype (bit-or (aget heap-u8 (+ fill-byte-offset 22)) + (bit-shift-left (aget heap-u8 (+ fill-byte-offset 23)) 8)) width (aget heap-i32 (+ fill-u32-offset 6)) height (aget heap-i32 (+ fill-u32-offset 7))] {:fill-opacity (u8->opacity alpha) :fill-image {:id (uuid/from-unsigned-parts a b c d) :width width :height height + :mtype (types.fills.impl/decode-mtype mtype) :keep-aspect-ratio (not (zero? (bit-and flags 0x01))) :name "sample"}}) diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 94d33e7dd2..c819579de0 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -145,6 +145,8 @@ pub struct ImageFill { height: i32, keep_aspect_ratio: bool, transform: Option, + // Opaque media type code, kept so the frontend can read it back. + mtype: u16, } impl ImageFill { @@ -156,6 +158,7 @@ impl ImageFill { height, keep_aspect_ratio, transform: None, + mtype: 0, } } @@ -174,9 +177,14 @@ impl ImageFill { height, keep_aspect_ratio, transform, + mtype: 0, } } + pub fn with_mtype(self, mtype: u16) -> Self { + Self { mtype, ..self } + } + pub fn id(&self) -> Uuid { self.id } @@ -197,6 +205,10 @@ impl ImageFill { self.height } + pub fn mtype(&self) -> u16 { + self.mtype + } + pub fn transform(&self) -> Option<&ImageFillTransform> { self.transform.as_ref() } diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 5d2a6e29e2..28ff005bec 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -179,7 +179,8 @@ mod tests { #[test] fn test_image_fill_round_trip() { - let image_fill = shapes::ImageFill::new(crate::uuid::Uuid::nil(), 0x80, 300, 200, true); + let image_fill = + shapes::ImageFill::new(crate::uuid::Uuid::nil(), 0x80, 300, 200, true).with_mtype(0x02); let fill = shapes::Fill::Image(image_fill); let raw_fill = RawFillData::try_from(&fill).expect("image fill must be serializable"); let bytes = <[u8; RAW_FILL_DATA_SIZE]>::from(raw_fill); diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index e6b2e634c5..86c9a12abe 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -44,7 +44,7 @@ pub struct RawImageFillData { d: u32, opacity: u8, flags: u8, - _pad: u16, + mtype: u16, width: i32, height: i32, transform_x: f32, @@ -76,7 +76,7 @@ impl From<&ImageFill> for RawImageFillData { d, opacity: image_fill.opacity(), flags, - _pad: 0, + mtype: image_fill.mtype(), width: image_fill.width(), height: image_fill.height(), transform_x: tx, @@ -110,6 +110,7 @@ impl From for ImageFill { keep_aspect_ratio, transform, ) + .with_mtype(value.mtype) } } From edd500302ce799b0519a8b5577a32eb62f4b52e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 7 Oct 2026 11:07:22 +0200 Subject: [PATCH 048/325] :arrow_up: Update ImageMagick to 7.1.2-32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- docker/devenv/Dockerfile | 2 +- docker/imagemagick/Dockerfile | 2 +- docker/images/Dockerfile.backend | 2 +- docker/images/Dockerfile.exporter | 2 +- manage.sh | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 995284048a..bd329d61f1 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -550,7 +550,7 @@ ENV LANG='C.UTF-8' \ SERENA_CONTEXT="claude-code" \ PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" -COPY --from=penpotapp/imagemagick:7.1.2-27-1 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick COPY --from=setup-jvm /opt/jdk /opt/jdk COPY --from=setup-jvm /opt/clojure /opt/clojure COPY --from=setup-node /opt/node /opt/node diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index 157897ca6e..bfb5544c7d 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \ DEBIAN_FRONTEND=noninteractive \ TZ=Etc/UTC -ARG IMAGEMAGICK_VERSION=7.1.2-27 +ARG IMAGEMAGICK_VERSION=7.1.2-32 RUN set -e; \ apt-get -qq update; \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index bf4a33b5cf..4937b04cda 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -106,7 +106,7 @@ RUN set -ex; \ chown -R penpot:penpot /opt/data; COPY --from=build /opt/jre /opt/jre -COPY --from=penpotapp/imagemagick:7.1.2-27-1 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick COPY files/imagemagick-policy.xml /opt/imagick/etc/ImageMagick-7/policy.xml diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 8d41a57273..03d468e72e 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -88,7 +88,7 @@ RUN set -ex; \ ARG BUNDLE_PATH="./bundle-exporter/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/ -COPY --from=penpotapp/imagemagick:7.1.2-27-1 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-32 /opt/imagick /opt/imagick WORKDIR /opt/penpot/exporter diff --git a/manage.sh b/manage.sh index 408792d781..f9a5824bfe 100755 --- a/manage.sh +++ b/manage.sh @@ -69,7 +69,7 @@ PENPOT_PORT_BASE_STORYBOOK=${PENPOT_STORYBOOK_PORT:?missing in defaults.env} export CURRENT_USER_ID=$(id -u); export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD); -export IMAGEMAGICK_VERSION=7.1.2-27 +export IMAGEMAGICK_VERSION=7.1.2-32 # Safe directory to avoid ownership errors with Git git config --global --add safe.directory /home/penpot/penpot || true From 65b248eec38a4159fd493ef593d77b6ec52eac25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 7 Oct 2026 13:08:02 +0200 Subject: [PATCH 049/325] :bug: Preserve team ID in Nitrate membership URL (#12157) --- backend/src/app/nitrate.clj | 2 +- backend/test/backend_tests/nitrate_test.clj | 34 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 backend/test/backend_tests/nitrate_test.clj diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 15214ac449..d91772f0cd 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -275,7 +275,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/teams/" - team-id + (str team-id "/") "users/" profile-id) schema:profile-organization params)) diff --git a/backend/test/backend_tests/nitrate_test.clj b/backend/test/backend_tests/nitrate_test.clj new file mode 100644 index 0000000000..c754539a95 --- /dev/null +++ b/backend/test/backend_tests/nitrate_test.clj @@ -0,0 +1,34 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.nitrate-test + (:require + [app.config :as cf] + [app.nitrate] + [clojure.test :as t])) + +(def ^:private generate-nitrate-uri + (ns-resolve 'app.nitrate 'generate-nitrate-uri)) + +(t/deftest generate-nitrate-uri-test + (binding [cf/config (assoc cf/config + :admin-console-uri + "http://ac.example/admin-console")] + (doseq [[label segments expected] + [["single segment" + ["api/connectivity"] + "http://ac.example/admin-console/api/connectivity"] + ["resource identifier as the final segment" + ["api/teams/" "team-id"] + "http://ac.example/admin-console/api/teams/team-id"] + ["nested team membership path" + ["api/teams/" "team-id/" "users/" "profile-id"] + "http://ac.example/admin-console/api/teams/team-id/users/profile-id"] + ["nested organization membership path" + ["api/organizations/" "organization-id/" "members/" "profile-id"] + "http://ac.example/admin-console/api/organizations/organization-id/members/profile-id"]]] + (t/testing label + (t/is (= expected (apply generate-nitrate-uri segments))))))) From d0cf3998074eec0dd7ebb787ebe01b849d8755c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 7 Oct 2026 13:14:58 +0200 Subject: [PATCH 050/325] :bug: Show trial status on subscription page (#12158) --- frontend/src/app/main/ui/settings/subscription.cljs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index b779fc2c70..f502f5285f 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -613,7 +613,9 @@ [:div {:class (stl/css :your-subscription)} [:h3 {:class (stl/css :plan-section-title)} (tr "subscription.settings.section-plan")] (if nitrate? - [:> plan-card* {:card-title (tr "subscription.settings.enterprise") + [:> plan-card* {:card-title (if (= (:status nitrate-license) "trialing") + (tr "subscription.settings.enterprise-trial") + (tr "subscription.settings.enterprise")) :cancel-at (when (:cancel-at nitrate-license) (tr "nitrate.subscription.active-until" (ct/format-inst (:cancel-at nitrate-license) "d MMMM, yyyy"))) :benefits-title (tr "subscription.settings.benefits.nitrate-unlimited-benefits") From 27ccb32ea1131dc193594078436106f7542aed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 7 Oct 2026 11:06:26 +0000 Subject: [PATCH 051/325] :construction_worker: Fix the devenv image build workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .github/workflows/build-docker-devenv.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 906adcd369..8b119c58f7 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -6,22 +6,34 @@ on: permissions: contents: read +concurrency: + group: build-docker-devenv + cancel-in-progress: false + jobs: build-and-push: name: Build and push DevEnv Docker image runs-on: penpot-standar-runner + timeout-minutes: 90 + env: + DOCKER_IMAGE: 'penpotapp/devenv' steps: - name: Set common environment variables run: | # Each job execution will use its own docker configuration. - echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV + echo "DOCKER_CONFIG=${RUNNER_TEMP}/.docker-${GITHUB_RUN_ID}-${GITHUB_JOB}" >> "$GITHUB_ENV" - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Set up QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + platforms: linux/amd64,linux/arm64 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -40,8 +52,6 @@ jobs: - name: Build and push DevEnv Docker image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - env: - DOCKER_IMAGE: 'penpotapp/devenv' with: context: ./docker/devenv/ file: ./docker/devenv/Dockerfile From c4c1c5a9559df51984a7c088067c5d1056d20db9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 7 Oct 2026 11:52:46 +0000 Subject: [PATCH 052/325] :bug: Fix media-processor upload-storage tests Update media-processor dependencies (multer 2.4, vitest 5 and others) and fix mockFile to emit Buffer chunks so multer memory storage Buffer.concat works. String chunks caused TypeError and 5s timeouts in two tests. AI-assisted-by: muse-spark-1.3-contributor --- media-processor/package.json | 22 +- media-processor/pnpm-lock.yaml | 639 ++++++++------------ media-processor/test/upload-storage.test.ts | 2 +- 3 files changed, 278 insertions(+), 385 deletions(-) diff --git a/media-processor/package.json b/media-processor/package.json index ee5114c7b9..f5373a591f 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -17,24 +17,24 @@ "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", "dependencies": { "express": "^5.2.1", - "multer": "^2.2.0", + "multer": "^2.4.0", "p-queue": "^9.3.3", - "pino": "^10.3.1", + "pino": "^10.4.0", "pino-loki": "^3.0.0", - "pino-pretty": "^13.1.3", - "sharp": "^0.35.4", - "zod": "^4.4.3" + "pino-pretty": "^13.2.0", + "sharp": "^0.35.5", + "zod": "^4.6.5" }, "devDependencies": { "@types/express": "^5.0.6", - "@types/multer": "^2.0.0", - "@types/node": "^26.3.0", + "@types/multer": "^2.3.0", + "@types/node": "^26.6.4", "@types/supertest": "^7.2.1", "esbuild": "^0.28.2", - "prettier": "^3.6.2", - "supertest": "^7.2.2", - "tsx": "^4.23.12", + "prettier": "^3.9.9", + "supertest": "^7.3.1", + "tsx": "^4.23.15", "typescript": "^7.0.2", - "vitest": "^4.1.11" + "vitest": "^5.0.3" } } diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 926fab2cd0..56410eee5c 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -170,36 +170,36 @@ importers: specifier: ^5.2.1 version: 5.2.1 multer: - specifier: ^2.2.0 - version: 2.2.0 + specifier: ^2.4.0 + version: 2.4.0 p-queue: specifier: ^9.3.3 version: 9.3.3 pino: - specifier: ^10.3.1 - version: 10.3.1 + specifier: ^10.4.0 + version: 10.4.0 pino-loki: specifier: ^3.0.0 version: 3.0.0 pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 + specifier: ^13.2.0 + version: 13.2.0 sharp: - specifier: ^0.35.4 - version: 0.35.4(@types/node@26.3.0) + specifier: ^0.35.5 + version: 0.35.5(@types/node@26.6.4) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.5 + version: 4.6.5 devDependencies: '@types/express': specifier: ^5.0.6 version: 5.0.6 '@types/multer': - specifier: ^2.0.0 - version: 2.2.0 + specifier: ^2.3.0 + version: 2.3.0 '@types/node': - specifier: ^26.3.0 - version: 26.3.0 + specifier: ^26.6.4 + version: 26.6.4 '@types/supertest': specifier: ^7.2.1 version: 7.2.1 @@ -207,20 +207,20 @@ importers: specifier: ^0.28.2 version: 0.28.2 prettier: - specifier: ^3.6.2 - version: 3.9.6 + specifier: ^3.9.9 + version: 3.9.9 supertest: - specifier: ^7.2.2 - version: 7.2.2 + specifier: ^7.3.1 + version: 7.3.1 tsx: - specifier: ^4.23.12 - version: 4.23.12 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^7.0.2 version: 7.0.2 vitest: - specifier: ^4.1.11 - version: 4.1.11(@types/node@26.3.0)(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)) + specifier: ^5.0.3 + version: 5.0.3(@types/node@26.6.4)(vite@7.3.6(@types/node@26.6.4)(tsx@4.23.15)) packages: @@ -387,167 +387,177 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -696,9 +706,6 @@ packages: cpu: [x64] os: [win32] - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -729,11 +736,11 @@ packages: '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/multer@2.2.0': - resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + '@types/multer@2.3.0': + resolution: {integrity: sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==} - '@types/node@26.3.0': - resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -873,11 +880,8 @@ packages: cpu: [x64] os: [win32] - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -887,20 +891,8 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -927,9 +919,6 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -960,10 +949,6 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -976,9 +961,6 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1039,8 +1021,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -1168,8 +1150,8 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -1218,8 +1200,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multer@2.2.0: - resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + multer@2.4.0: + resolution: {integrity: sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==} engines: {node: '>= 10.16.0'} nanoid@3.3.16: @@ -1265,9 +1247,6 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1275,6 +1254,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -1283,23 +1266,23 @@ packages: engines: {node: '>=20'} hasBin: true - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + pino-pretty@13.2.0: + resolution: {integrity: sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==} hasBin: true pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + pino@10.4.0: + resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==} hasBin: true postcss@8.5.20: resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} engines: {node: ^10 || ^12 || >=14} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} engines: {node: '>=14'} hasBin: true @@ -1328,14 +1311,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} @@ -1348,9 +1323,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -1377,8 +1349,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1402,12 +1374,12 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + sonic-boom@5.0.1: + resolution: {integrity: sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1416,9 +1388,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1430,9 +1399,6 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -1441,29 +1407,26 @@ packages: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} - supertest@7.2.2: - resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + supertest@7.3.1: + resolution: {integrity: sha512-vNaNCFoCR/edDbErQZp5KLNT8R4dSPHX7rlDLoE8hWLAm/878j6ShMbe8HK/E6RwwuhlSXVelXAIg9AxR5WWPw==} engines: {node: '>=14.18.0'} thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1471,8 +1434,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1484,24 +1447,18 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -1546,23 +1503,23 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -1587,16 +1544,16 @@ packages: jsdom: optional: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} hasBin: true wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} snapshots: @@ -1685,112 +1642,121 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/hashes@1.8.0': {} '@paralleldrive/cuid2@2.3.1': @@ -1874,12 +1840,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@standard-schema/spec@1.1.0': {} - '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.3.0 + '@types/node': 26.6.4 '@types/chai@5.2.3': dependencies: @@ -1888,7 +1852,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 26.3.0 + '@types/node': 26.6.4 '@types/cookiejar@2.1.5': {} @@ -1898,7 +1862,7 @@ snapshots: '@types/express-serve-static-core@5.1.2': dependencies: - '@types/node': 26.3.0 + '@types/node': 26.6.4 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1913,13 +1877,13 @@ snapshots: '@types/methods@1.1.4': {} - '@types/multer@2.2.0': + '@types/multer@2.3.0': dependencies: '@types/express': 5.0.6 - '@types/node@26.3.0': + '@types/node@26.6.4': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/qs@6.15.1': {} @@ -1927,18 +1891,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 26.3.0 + '@types/node': 26.6.4 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.3.0 + '@types/node': 26.6.4 '@types/superagent@8.1.11': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 26.3.0 + '@types/node': 26.6.4 form-data: 4.0.6 '@types/supertest@7.2.1': @@ -2006,46 +1970,16 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/expect@4.1.11': + '@vitest/mocker@5.0.3(vite@7.3.6(@types/node@26.6.4)(tsx@4.23.15))': dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12))': - dependencies: - '@vitest/spy': 4.1.11 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 0.30.21 + magic-string: 1.4.3 optionalDependencies: - vite: 7.3.6(@types/node@26.3.0)(tsx@4.23.12) + vite: 7.3.6(@types/node@26.6.4)(tsx@4.23.15) - '@vitest/pretty-format@4.1.11': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.11': - dependencies: - '@vitest/utils': 4.1.11 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.11': {} - - '@vitest/utils@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + '@vitest/spy@5.0.3': {} accepts@2.0.0: dependencies: @@ -2076,8 +2010,6 @@ snapshots: transitivePeerDependencies: - supports-color - buffer-from@1.1.2: {} - busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -2104,21 +2036,12 @@ snapshots: component-emitter@1.3.1: {} - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - content-disposition@1.1.0: {} content-type@1.0.5: {} content-type@2.0.0: {} - convert-source-map@2.0.0: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -2160,7 +2083,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: dependencies: @@ -2341,9 +2264,9 @@ snapshots: joycon@3.1.1: {} - magic-string@0.30.21: + magic-string@1.4.3: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 math-intrinsics@1.1.0: {} @@ -2373,11 +2296,10 @@ snapshots: ms@2.1.3: {} - multer@2.2.0: + multer@2.4.0: dependencies: append-field: 1.0.0 busboy: 1.6.0 - concat-stream: 2.0.0 type-is: 1.6.18 nanoid@3.3.16: {} @@ -2409,12 +2331,12 @@ snapshots: path-to-regexp@8.4.2: {} - pathe@2.0.3: {} - picocolors@1.1.1: {} picomatch@4.0.5: {} + picomatch@4.0.7: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -2424,7 +2346,7 @@ snapshots: pino-abstract-transport: 3.0.0 pump: 3.0.4 - pino-pretty@13.1.3: + pino-pretty@13.2.0: dependencies: colorette: 2.0.20 dateformat: 4.6.3 @@ -2437,12 +2359,12 @@ snapshots: pino-abstract-transport: 3.0.0 pump: 3.0.4 secure-json-parse: 4.1.0 - sonic-boom: 4.2.1 + sonic-boom: 5.0.1 strip-json-comments: 5.0.3 pino-std-serializers@7.1.0: {} - pino@10.3.1: + pino@10.4.0: dependencies: '@pinojs/redact': 0.4.0 atomic-sleep: 1.0.0 @@ -2451,7 +2373,7 @@ snapshots: pino-std-serializers: 7.1.0 process-warning: 5.0.0 quick-format-unescaped: 4.0.4 - real-require: 0.2.0 + real-require: 1.0.0 safe-stable-stringify: 2.5.0 sonic-boom: 4.2.1 thread-stream: 4.2.0 @@ -2462,7 +2384,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prettier@3.9.6: {} + prettier@3.9.9: {} process-warning@5.0.0: {} @@ -2492,14 +2414,6 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - real-require@0.2.0: {} - real-require@1.0.0: {} rollup@4.62.2: @@ -2543,8 +2457,6 @@ snapshots: transitivePeerDependencies: - supports-color - safe-buffer@5.2.1: {} - safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} @@ -2580,38 +2492,38 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.4(@types/node@26.3.0): + sharp@0.35.5(@types/node@26.6.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 - '@types/node': 26.3.0 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 26.6.4 side-channel-list@1.0.1: dependencies: @@ -2641,28 +2553,24 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 + sonic-boom@5.0.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} split2@4.2.0: {} - stackback@0.0.2: {} - statuses@2.0.2: {} std-env@4.2.0: {} streamsearch@1.1.0: {} - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-json-comments@5.0.3: {} superagent@10.3.0: @@ -2679,7 +2587,7 @@ snapshots: transitivePeerDependencies: - supports-color - supertest@7.2.2: + supertest@7.3.1: dependencies: cookie-signature: 1.2.2 methods: 1.1.2 @@ -2691,23 +2599,21 @@ snapshots: dependencies: real-require: 1.0.0 - tinybench@2.9.0: {} + tinybench@6.2.0: {} - tinyexec@1.2.4: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - tinyrainbow@3.1.0: {} - toidentifier@1.0.1: {} tslib@2.8.1: optional: true - tsx@4.23.12: + tsx@4.23.15: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -2724,8 +2630,6 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typedarray@0.0.6: {} - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -2749,15 +2653,13 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.3.0: {} + undici-types@8.9.0: {} unpipe@1.0.0: {} - util-deprecate@1.0.2: {} - vary@1.1.2: {} - vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12): + vite@7.3.6(@types/node@26.6.4)(tsx@4.23.15): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -2766,42 +2668,33 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.3.0 + '@types/node': 26.6.4 fsevents: 2.3.3 - tsx: 4.23.12 + tsx: 4.23.15 - vitest@4.1.11(@types/node@26.3.0)(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)): + vitest@5.0.3(@types/node@26.6.4)(vite@7.3.6(@types/node@26.6.4)(tsx@4.23.15)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.1 + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@7.3.6(@types/node@26.6.4)(tsx@4.23.15)) + chai: 6.2.2 + es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 0.30.21 + magic-string: 1.4.3 obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 + tinybench: 6.2.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 7.3.6(@types/node@26.3.0)(tsx@4.23.12) - why-is-node-running: 2.3.0 + vite: 7.3.6(@types/node@26.6.4)(tsx@4.23.15) + why-is-node-running: 3.2.1 optionalDependencies: - '@types/node': 26.3.0 + '@types/node': 26.6.4 transitivePeerDependencies: - msw - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 + why-is-node-running@3.2.1: {} wrappy@1.0.2: {} - zod@4.4.3: {} + zod@4.6.5: {} diff --git a/media-processor/test/upload-storage.test.ts b/media-processor/test/upload-storage.test.ts index b2613e533a..79a8250535 100644 --- a/media-processor/test/upload-storage.test.ts +++ b/media-processor/test/upload-storage.test.ts @@ -13,7 +13,7 @@ function mockReq(contentLength?: string): Request { } function mockFile(content: string = "test content") { - const stream = Readable.from([content]); + const stream = Readable.from([Buffer.from(content)]); return { fieldname: "file", originalname: "test.txt", From 6473dc85abbb352e2cbda40e2d3c6e409e157d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 7 Oct 2026 12:06:06 +0000 Subject: [PATCH 053/325] :construction_worker: Notify new devenv images in penpotdev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- .github/workflows/build-docker-devenv.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 8b119c58f7..a1c99e2bdd 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -71,4 +71,13 @@ jobs: TEXT: | 🚀 *[PENPOT] New devenv available* 📄 You may want to update your devenv. - @alvaro + + - name: Notify Mattermost (developers) + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 + with: + MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} + MATTERMOST_CHANNEL: penpotdev + TEXT: | + 🚀 *[PENPOT] New devenv available* + 📄 You may want to update your devenv. + (\cc @alvaro) From 31000c3055bc5477e31b4bb130e956876c3352bc Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 7 Oct 2026 14:52:46 +0200 Subject: [PATCH 054/325] :zap: Enable rustc wasm SIMD for render-wasm builds (#12163) Set RUSTFLAGS target-feature=+simd128 on the emscripten target so our Rust code can autovec; Skia SIMD remains via the -simd prebuild and EMCC -msimd128. --- render-wasm/_build_env | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 2673c81ba4..0ed8f2bfe4 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -22,6 +22,13 @@ export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} +# Let rustc emit wasm SIMD for our Rust code (autovec / core::arch::wasm32). +# Skia SIMD still comes from the -simd prebuild + -msimd128 in EMCC_CFLAGS. +# Gate on the emscripten target so native `./test` is unaffected. +if [ "$CARGO_BUILD_TARGET" = "wasm32-unknown-emscripten" ]; then + export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+simd128" +fi + # 256 MB of initial heap to perform less # initial calls to memory grow. export EM_INITIAL_HEAP=$((256 * 1024 * 1024)) From be17fabc6a5a013efc749f0bf44942854ab10c3c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 12:20:51 +0000 Subject: [PATCH 055/325] :tada: Introduce global plugins and move MCP to application scope Global plugin lifecycle: - Add optional manifest scope "global" for plugins that remain active across dashboard and workspace navigation within one browser tab. Existing plugins retain workspace scope by default. - Preserve global sandboxes when a workspace ends or another plugin loads, including when that other plugin fails to initialize. - Avoid duplicate instances of an already-running global plugin. - Close global plugins on explicit closure or logout, with idempotent cleanup of listeners, timers and UI resources. Global scope does not imply automatic startup or persistence across a browser reload. Plugin API and navigation: - Keep a permanently live penpot facade whose getters follow the current workspace, while user, theme and UI facilities remain available on the dashboard. - Expose penpotMgmt to global plugins with workspace metadata, none/loading/ready status and workspacechange subscriptions. - Add awaitable openFile(fileId, {teamId}) navigation in the current tab, defaulting to the current team and resolving only once the requested file and its active page are ready. - Reject invalid identifiers, failed or superseded navigation and waits exceeding 30 seconds; release navigation watchers on completion. - Guard workspace-bound operations while no workspace is ready and return null from root, currentFile and currentPage in that state. - Require content:read for the initial management API. Callers must reacquire file, page and shape references after changing files. MCP lifecycle and connection controls: - Make MCP a global plugin and initialize its integrated controller per authenticated application session instead of per workspace. - Load the enabled integration with a valid token on the dashboard as well as in a file, retaining its existing hidden-iframe implementation. - Keep enabling and connecting separate: an enabled plugin starts idle, and each tab connects explicitly through dashboard or workspace controls. - Preserve the iframe, WebSocket, heartbeat and reconnect machinery during navigation; disconnect on request and close on disable/logout. MCP sessions and execution: - Derive the short MCP session ID solely from currentUser.sessionId, excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding to produce a stable, copyable 10-character identifier. - Allow sessions without an open file and update workspace metadata over the existing connection without replacing its routing identity. - Refresh context after the connection handshake so initialization does not leave stale file metadata. - Expose penpotMgmt to execute_code, preserve execution storage across navigation and skip layout waits when no workspace is ready. - Update API types, tool guidance, documentation and the changelog. Validation: - Cover authenticated startup, workspace survival, logout cleanup, readiness, navigation failure, duplicate loads and session routing. - Verify that short IDs remain stable across files and reconnects and that disconnect cancels pending asynchronous ID initialization. - Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests, builds, TypeScript checks and frontend/runtime lint. - Pass all 73 MCP server tests with --test-force-exit; the normal runner still stays alive after assertions finish. - Verify a live dashboard-to-file-to-dashboard round trip without a document or iframe reload, preserving facade identity and storage. AI-assisted-by: gpt-6 --- common/src/app/common/types/plugins.cljc | 1 + docs/mcp/index.md | 9 +- frontend/src/app/main.cljs | 2 + frontend/src/app/main/data/plugins.cljs | 6 +- frontend/src/app/main/data/workspace.cljs | 13 +-- frontend/src/app/main/data/workspace/mcp.cljs | 30 ++++++- .../src/app/main/ui/dashboard/sidebar.cljs | 23 +++++ frontend/src/app/plugins/api.cljs | 4 + frontend/src/app/plugins/events.cljs | 20 ++++- frontend/src/app/plugins/management.cljs | 78 +++++++++++++++++ frontend/src/app/plugins/register.cljs | 1 + .../data/workspace_mcp_test.cljs | 37 +++++++- .../plugins/management_test.cljs | 83 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + mcp/README.md | 12 ++- mcp/packages/common/src/types.ts | 5 +- mcp/packages/plugin/public/manifest.json | 1 + .../plugin/src/ConnectionMetadata.test.ts | 71 ++++++++++++--- mcp/packages/plugin/src/SessionId.test.ts | 14 +-- mcp/packages/plugin/src/SessionId.ts | 4 +- mcp/packages/plugin/src/index.d.ts | 1 + mcp/packages/plugin/src/main.ts | 12 ++- mcp/packages/plugin/src/plugin.ts | 30 ++++++- .../task-handlers/ExecuteCodeTaskHandler.ts | 3 +- .../server/src/PluginBridge.sessions.test.ts | 26 ++++++ mcp/packages/server/src/PluginBridge.ts | 12 ++- .../server/src/dispatch/TaskDispatcher.ts | 2 +- .../server/src/tools/ExecuteCodeTool.ts | 6 +- plugins/CHANGELOG.md | 2 + plugins/libs/plugin-types/index.d.ts | 27 +++++- .../libs/plugins-runtime/src/lib/api/index.ts | 56 ++++++++++-- .../src/lib/create-sandbox.spec.ts | 1 + .../plugins-runtime/src/lib/create-sandbox.ts | 9 +- .../src/lib/load-plugin-real-path.spec.ts | 86 ++++++++++++++++++- .../plugins-runtime/src/lib/load-plugin.ts | 38 +++++--- .../src/lib/models/manifest.schema.ts | 1 + .../plugins-runtime/src/lib/plugin-manager.ts | 13 +-- 37 files changed, 647 insertions(+), 94 deletions(-) create mode 100644 frontend/src/app/plugins/management.cljs create mode 100644 frontend/test/frontend_tests/plugins/management_test.cljs diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 035be0efb3..b9a1ca5330 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -58,6 +58,7 @@ [:map [:plugin-id :string] [:version {:optional true} :int] + [:scope {:optional true} [:enum "workspace" "global"]] [:name [:string {:max (:name registry-entry-max-lengths)}]] [:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]] [:host [:string {:max (:host registry-entry-max-lengths)}]] diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 815a468fea..47a4c55803 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -74,16 +74,17 @@ There are three key pieces: Some important concepts for users: * **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key. * **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup. -* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID. Several tabs can connect independently, including tabs showing the same file. +* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID derived from the tab's user session. Several tabs can connect independently, including tabs showing the same file. * **Current page**: the page open in the selected session. Switching browser focus to another tab does not change which session the agent uses. ### Working with multiple files Connect the tabs you want to use: -* **Remote MCP**: open each file and choose **MCP → Connect** in the toolbar. Use **Disconnect** in the same menu to disconnect that tab. +* **Remote MCP**: choose **MCP: Connect** in the dashboard sidebar or **MCP → Connect** in a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. * **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. * Connecting or disconnecting one tab does not disconnect the others. +* A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file. Choose a session for your agent: @@ -176,7 +177,7 @@ When this happens, MCP fails fast instead of waiting for a long task timeout: To recover: * If the tab was only suspended, focus it, wait until MCP reconnects, and retry the prompt. -* If the tab was unloaded or reloaded, open the file and connect again. Copy the new session ID if your agent was using the previous one. +* If the tab was unloaded or reloaded, connect again from the dashboard or a file. Copy the new session ID if your agent was using the previous one. To reduce the chances of the browser putting Penpot to sleep during long MCP sessions: @@ -271,7 +272,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted 1. Open **Your account → Integrations**. 2. In the **MCP Server** section, read the short description to confirm that feature is available for your account. -3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each file explicitly. +3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each tab explicitly. 4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely. * Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples. 5. Once enabled, you will see: diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index c7d87ba8ba..06ffc849da 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -16,6 +16,7 @@ [app.main.data.event :as ev] [app.main.data.profile :as dp] [app.main.data.websocket :as ws] + [app.main.data.workspace.mcp :as mcp] [app.main.errors] [app.main.features :as feat] [app.main.rasterizer :as thr] @@ -81,6 +82,7 @@ (watch [_ _ stream] (rx/merge (rx/of (ev/initialize) + (mcp/initialize) (dp/refresh-profile)) ;; Watch for profile deletion events diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 33d898cf69..e9b15e0849 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -58,7 +58,7 @@ (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) (defn start-plugin! - [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] + [{:keys [plugin-id name version description host code permissions allow-background scope]} ^js extensions] (let [load-plugin (unchecked-get ug/global "ɵloadPlugin")] (if (fn? load-plugin) (-> (load-plugin @@ -69,6 +69,7 @@ :host host :code code :allowBackground (boolean allow-background) + :scope scope :permissions (apply array permissions)} nil extensions) @@ -82,7 +83,7 @@ :action "start-plugin!")))) (defn- load-plugin! - [{:keys [plugin-id name version description host code icon permissions]}] + [{:keys [plugin-id name version description host code icon permissions scope]}] (st/emit! (pflag/initialize plugin-id version) (save-current-plugin plugin-id)) @@ -96,6 +97,7 @@ :host host :code code :icon icon + :scope scope :permissions (apply array permissions)} (fn [] (st/emit! (remove-current-plugin plugin-id)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index b04bf5394b..e4b79d7b66 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -55,7 +55,6 @@ [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layout :as layout] [app.main.data.workspace.libraries :as dwl] - [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.path :as dwdp] @@ -77,7 +76,6 @@ [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] - [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] @@ -259,15 +257,8 @@ ptk/WatchEvent (watch [_ _ _] - (rx/merge - (rx/of (dp/check-open-plugin) - (fdf/fix-deleted-fonts-for-local-library file-id)) - (if (contains? cf/flags :mcp) - ;; We wait the plugin runtime to be ready before launch the - ;; mcp initialization - (->> (rx/from (preg/wait-for-runtime)) - (rx/map (fn [_] (mcp/init)))) - (rx/empty)))))) + (rx/of (dp/check-open-plugin) + (fdf/fix-deleted-fonts-for-local-library file-id))))) (defn- compute-shape-stats "Compute shape statistics in a single pass over pages-index. diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 74884289c4..f3e3817239 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -12,13 +12,14 @@ [app.common.uri :as u] [app.config :as cf] [app.main.broadcast :as mbc] + [app.main.data.auth :as-alias auth] [app.main.data.plugins :as dp] [app.main.data.profile :as du] - [app.main.data.workspace :as-alias dw] [app.main.store :as st] [app.plugins.register :as preg] [app.util.timers :as ts] [beicon.v2.core :as rx] + [beicon.v2.operators :as rxo] [potok.v2.core :as ptk])) (def reconnect-fallback-interval 60000) @@ -36,6 +37,7 @@ :plugin-id preg/mcp-plugin-id :description "This plugin enables interaction with the Penpot MCP server" :allow-background true + :scope "global" :permissions #{"user:read" "library:read" "library:write" @@ -147,7 +149,7 @@ (assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) stopper-s (rx/merge - (rx/filter (ptk/type? ::dw/finalize-workspace) stream) + (rx/filter (ptk/type? ::auth/logged-out) stream) (rx/filter (ptk/type? ::stop-mcp-plugin) stream) (rx/filter (ptk/type? ::init) stream)) @@ -234,7 +236,7 @@ ptk/WatchEvent (watch [_ state stream] (let [stopper-s (rx/merge - (rx/filter (ptk/type? ::dw/finalize-workspace) stream) + (rx/filter (ptk/type? ::auth/logged-out) stream) (rx/filter (ptk/type? ::init) stream)) mcp-state (get state :mcp)] @@ -274,3 +276,25 @@ (stop-mcp-plugin)))))) (rx/take-until stopper-s)))))) + +(defn initialize + "Initialize the MCP controller for each authenticated app session." + [] + (ptk/reify ::initialize + ptk/WatchEvent + (watch [_ _ stream] + (if (contains? cf/flags :mcp) + (->> stream + (rx/filter #(or (ptk/type? ::du/set-profile %) + (ptk/type? ::auth/logged-out %))) + (rx/map #(when (ptk/type? ::du/set-profile %) + (:id (deref %)))) + (rx/pipe (rxo/distinct-contiguous)) + (rx/switch-map + (fn [profile-id] + (if profile-id + (->> (rx/from (preg/wait-for-runtime)) + (rx/map (fn [_] (init)))) + (rx/empty)))) + (rx/take-until (rx/filter (ptk/type? ::initialize) stream))) + (rx/empty))))) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 2f37187244..386d1dc791 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -17,6 +17,7 @@ [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] + [app.main.data.workspace.mcp :as mcp] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] @@ -255,6 +256,13 @@ (get default-project :id) team-id (get team :id) + mcp-state (mf/deref refs/mcp) + mcp-requested? (:connection-requested mcp-state) + toggle-mcp (mf/use-fn + (mf/deps mcp-requested?) + #(st/emit! (if mcp-requested? + (mcp/user-disconnect-mcp) + (mcp/connect-mcp)))) projects? (= section :dashboard-recent) fonts? (= section :dashboard-fonts) @@ -382,6 +390,21 @@ [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] + (when (and (contains? cf/flags :mcp) + (:enabled mcp-state) + (:token-valid mcp-state)) + [:div {:class (stl/css :sidebar-content-section)} + [:ul {:class (stl/css :sidebar-nav)} + [:li {:class (stl/css :sidebar-nav-item)} + [:> link* {:action toggle-mcp + :keyboard-action toggle-mcp + :class (stl/css :sidebar-link)} + [:span {:class (stl/css :element-title)} + "MCP: " + (if mcp-requested? + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]]) + [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} [:div {:class (stl/css :sidebar-section-title)} diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 226b324c1f..2f0944f800 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -43,6 +43,7 @@ [app.plugins.history :as history] [app.plugins.library :as library] [app.plugins.local-storage :as local-storage] + [app.plugins.management :as management] [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.reflow :as wrfp] @@ -88,6 +89,9 @@ :$plugin {:enumerable false :get (fn [] plugin-id)} ;; Public properties + :management + {:get (fn [] (management/create-context))} + :version {:this true :get (constantly (:base cf/version))} diff --git a/frontend/src/app/plugins/events.cljs b/frontend/src/app/plugins/events.cljs index 884a1e0bac..51ac7c596f 100644 --- a/frontend/src/app/plugins/events.cljs +++ b/frontend/src/app/plugins/events.cljs @@ -10,6 +10,7 @@ [app.main.data.helpers :as dsh] [app.main.store :as st] [app.plugins.file :as file] + [app.plugins.management :as management] [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.shape :as shape] @@ -19,6 +20,21 @@ (defmulti handle-state-change (fn [type _ _ _ _] type)) +(defmethod handle-state-change "workspacechange" + [_ _ old-val new-val _] + (let [old-workspace (management/workspace-context old-val) + new-workspace (management/workspace-context new-val)] + (if (= old-workspace new-workspace) + ::not-changed + (clj->js new-workspace)))) + +(defmethod handle-state-change "logout" + [_ _ old-val new-val _] + (if (and (:profile-id old-val) + (not= (:profile-id old-val) (:profile-id new-val))) + ::void + ::not-changed)) + (defmethod handle-state-change "finish" [_ _ old-val new-val _] (let [old-file-id (:current-file-id old-val) @@ -123,7 +139,9 @@ (try (let [result (handle-state-change type plugin-id old-val new-val props)] (when (not= ::not-changed result) - (debounced-callback result))) + (if (contains? #{"workspacechange" "logout"} type) + (safe-callback result) + (debounced-callback result)))) (catch :default cause (.error js/console cause))))) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs new file mode 100644 index 0000000000..bff5d9292d --- /dev/null +++ b/frontend/src/app/plugins/management.cljs @@ -0,0 +1,78 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.plugins.management + (:require + [app.common.uuid :as uuid] + [app.main.data.helpers :as dsh] + [app.main.router :as rt] + [app.main.store :as st] + [app.util.object :as obj])) + +(defn workspace-context + [state] + (let [params (rt/get-params state) + file-id (when (= :workspace (rt/lookup-name state)) + (uuid/parse* (:file-id params))) + ready? (and file-id + (nil? (:exception state)) + (= file-id (:current-file-id state) (:workspace-ready state)) + (some? (:current-page-id state)) + (some? (dsh/lookup-page state file-id (:current-page-id state))))] + {:status (cond ready? "ready" file-id "loading" :else "none") + :fileId (some-> file-id str) + :fileName (when file-id (:name (dsh/lookup-file state file-id))) + :teamId (some-> (:current-team-id state) str)})) + +(defn open-file + [file-id options] + (let [file-id (uuid/parse* file-id) + team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (cond + (or (nil? file-id) (nil? team-id)) + (js/Promise.reject (js/Error. "Expected a file UUID and a team UUID")) + + (and (= "ready" (:status (workspace-context @st/state))) + (= file-id (:current-file-id @st/state)) + (= team-id (:current-team-id @st/state))) + (js/Promise.resolve nil) + + :else + (js/Promise. + (fn [resolve reject] + (let [key (js/Symbol) + initial-route (:route @st/state) + timer (atom nil) + finish (fn [error] + (remove-watch st/state key) + (js/clearTimeout @timer) + (if error (reject error) (resolve nil)))] + (reset! timer (js/setTimeout #(finish (js/Error. "Timed out opening file")) 30000)) + (add-watch + st/state key + (fn [_ _ _ state] + (let [workspace (workspace-context state) + target? (and (= (str file-id) (:fileId workspace)) + (= (str team-id) (:team-id (rt/get-params state))))] + (cond + (:exception state) + (finish (js/Error. "Unable to open file")) + + (and target? (= "ready" (:status workspace))) + (finish nil) + + (and (not= initial-route (:route state)) (not target?)) + (finish (js/Error. "File navigation was superseded")))))) + (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) + +(defn create-context + [] + (obj/reify {:name "PenpotManagementContext"} + :workspace + {:get (fn [] (clj->js (workspace-context @st/state)))} + :openFile open-file)) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index c70b4f9e7a..fc709427d4 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -102,6 +102,7 @@ {:plugin-id plugin-id :url (str plugin-url) :version vers + :scope (obj/get manifest "scope") :name name :description desc :host origin diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 8bd36d07aa..802be3ae2f 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -8,14 +8,43 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.workspace.mcp :as mcp] + [app.plugins.register :as preg] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) +(t/deftest ^:async test-initialize-once-per-authenticated-session + (let [stream (rx/subject) + events (atom []) + profile {:id (uuid/next)}] + (await + (mock/with-mocks* + {cf/flags #{:mcp} + preg/wait-for-runtime (fn [] (js/Promise.resolve true))} + (let [subscription (rx/subs! #(swap! events conj (ptk/type %)) + (ptk/watch (mcp/initialize) {} stream))] + (try + (rx/push! stream (du/set-profile profile)) + (await (a/wait-for #(= 1 (count @events)))) + (rx/push! stream (du/set-profile profile)) + (rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (a/settle)) + (t/is (= [:app.main.data.workspace.mcp/init] @events)) + (rx/push! stream (ptk/data-event :app.main.data.auth/logged-out)) + (rx/push! stream (du/set-profile profile)) + (await (a/wait-for #(= 2 (count @events)))) + (t/is (= [:app.main.data.workspace.mcp/init + :app.main.data.workspace.mcp/init] @events)) + (finally + (rx/dispose! subscription) + (rx/end! stream)))))))) + (t/deftest test-update-mcp-status (t/testing "enables MCP in profile props and mcp state" (let [state {:profile {:props {:mcp-enabled false}} :mcp {}} @@ -151,7 +180,7 @@ (t/is (= "disconnected" (get-in result [:mcp :connection-status]))) (t/is (nil? (get-in result [:mcp :session-id]))))) -(t/deftest test-init-clears-previous-file-connection +(t/deftest test-init-clears-previous-app-connection (let [state {:profile {:props {:mcp-enabled true}} :mcp {:connection-requested true :connection-status "connected" @@ -165,8 +194,8 @@ (let [state {:mcp {:connection-requested false :connection-status "disconnected"}}] (t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state))))) -(t/deftest test-plugin-callbacks-stop-with-workspace - (doseq [stop-event [:app.main.data.workspace/finalize-workspace +(t/deftest test-plugin-callbacks-stop-with-app-session + (doseq [stop-event [:app.main.data.auth/logged-out :app.main.data.workspace.mcp/init]] (let [stream (rx/subject) extension (atom nil) @@ -179,6 +208,8 @@ (.on @extension "connect" #(swap! calls inc)) (rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect)) (t/is (= 1 @calls)) + (rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (t/is (zero? @closed)) (rx/push! stream (ptk/data-event stop-event)) (t/is (= 1 @closed)) (t/is (false? (.isConnectionRequested @extension))) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs new file mode 100644 index 0000000000..1e11a83367 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -0,0 +1,83 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.plugins.management-test + (:require + [app.common.uuid :as uuid] + [app.main.store :as st] + [app.plugins.events :as events] + [app.plugins.management :as management] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock])) + +(def file-id (uuid/next)) +(def page-id (uuid/next)) +(def team-id (uuid/next)) + +(def dashboard + {:route {:data {:name :dashboard-recent}} + :current-team-id team-id}) + +(def loading + (assoc dashboard + :route {:data {:name :workspace} + :params {:query {:file-id (str file-id) :team-id (str team-id)}}} + :current-file-id file-id + :workspace-ready file-id)) + +(def ready + (assoc loading + :current-page-id page-id + :files {file-id {:id file-id :name "Design" + :data {:pages-index {page-id {:id page-id :objects {}}}}}})) + +(t/deftest workspace-readiness-requires-an-initialized-page + (t/is (= "none" (:status (management/workspace-context dashboard)))) + (t/is (= "loading" (:status (management/workspace-context loading)))) + (t/is (= "ready" (:status (management/workspace-context ready)))) + (t/is (= "none" (:status (management/workspace-context (assoc ready :route (:route dashboard))))))) + +(t/deftest ^:async open-file-waits-for-page-readiness + (let [state (atom dashboard) + seen (atom []) + done? (atom false)] + (await + (mock/with-mocks* + {st/state state + st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0)) + ([_ & _] nil))} + (let [listener (events/add-listener "workspacechange" (str uuid/zero) + #(swap! seen conj (.-status %)) nil) + result (-> (management/open-file (str file-id) nil) + (.then #(reset! done? true)))] + (try + (await (a/wait-for #(= ["loading"] @seen))) + (t/is (false? @done?)) + (reset! state ready) + (await result) + (t/is (= ["loading" "ready"] @seen)) + (t/is (true? @done?)) + (finally + (events/remove-listener listener)))))))) + +(t/deftest ^:async open-file-rejects-superseded-navigation + (let [state (atom dashboard)] + (await + (mock/with-mocks* + {st/state state + st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0)) + ([_ & _] nil))} + (let [result (-> (management/open-file (str file-id) nil) + (.then (fn [] nil) (fn [error] (.-message error))))] + (await (a/wait-for #(= loading @state))) + (reset! state {:route {:data {:name :settings-profile}}}) + (t/is (= "File navigation was superseded" (await result)))))))) + +(t/deftest ^:async open-file-rejects-invalid-identifiers + (let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)}) + (.then (fn [] nil) (fn [error] (.-message error))))] + (t/is (= "Expected a file UUID and a team UUID" (await result))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a12c63a205..a17d9b3149 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -73,6 +73,7 @@ [frontend-tests.plugins.interactions-test] [frontend-tests.plugins.library-test] [frontend-tests.plugins.local-storage-test] + [frontend-tests.plugins.management-test] [frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-test] [frontend-tests.plugins.parser-test] @@ -214,6 +215,7 @@ 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.events-test + 'frontend-tests.plugins.management-test 'frontend-tests.plugins.file-test 'frontend-tests.plugins.flex-test 'frontend-tests.plugins.format-test diff --git a/mcp/README.md b/mcp/README.md index 1661f0d019..7018537793 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -33,6 +33,16 @@ This repository thus contains not only the MCP server implementation itself but also the supporting Penpot MCP Plugin (see section [Repository Structure](#repository-structure) below). +## Global plugin lifecycle + +- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden. +- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it. +- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`. +- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`. +- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise. +- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation. +- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission. + ## Demonstration [![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4) @@ -243,7 +253,7 @@ of the prompt input area. Connect the files you want to use: * Open each file in a separate Penpot tab, run the plugin, and connect it to the same MCP server. -* Each connection has its own short session ID, shown in the plugin UI with a **Copy** button. +* Each browser tab uses a short session ID derived only from `penpot.currentUser.sessionId`, shown in the plugin UI with a **Copy** button. The ID stays the same across workspace navigation. * Multiple tabs can connect to the same file. Connecting or disconnecting one tab does not disconnect the others. Choose a session for your agent: diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index 3809a14fdd..f9d7cde400 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -1,8 +1,9 @@ /** Metadata identifying one connected Penpot plugin session. */ export interface PenpotSession { sessionId: string; - fileId: string; - fileName: string; + fileId: string | null; + fileName: string | null; + workspaceState?: "none" | "loading" | "ready"; } /** First message sent by the plugin on each new WebSocket connection. */ diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index 022c7ece9c..c67296f1db 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -3,6 +3,7 @@ "code": "plugin.js", "icon": "icon.jpg", "version": 2, + "scope": "global", "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["user:read", "content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] } diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 954eefeb9f..91aadb8188 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -92,7 +92,7 @@ test("sends file metadata before marking a plugin connection ready", async () => }; await ui.message(initialization); const registeredSession = ui.sockets[0].sent[0].session; - assert.equal(registeredSession.sessionId, "pq3gxqddgj"); + assert.equal(registeredSession.sessionId, "ftdmhltgwk"); assert.notEqual(registeredSession.sessionId, sessionId); assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId }); assert.equal( @@ -100,7 +100,10 @@ test("sends file metadata before marking a plugin connection ready", async () => false ); ui.sockets[0].receive({ type: "initialized" }); - assert.equal(ui.messages.at(-1).status, "connected"); + assert.equal( + ui.messages.filter((message) => message.type === "update-connection-status").at(-1).status, + "connected" + ); }); test("reconnects with the same session ID and ignores metadata for the old socket", async () => { @@ -163,6 +166,12 @@ test("plugin reads the current file for each connection metadata request", () => runInNewContext(compile("plugin.ts"), { exports: {}, penpot, + penpotMgmt: { + get workspace() { + return { status: "ready", fileId: penpot.currentFile.id, fileName: penpot.currentFile.name }; + }, + on() {}, + }, mcp: undefined, require: () => ({ ExecuteCodeTaskHandler: class {} }), }); @@ -173,17 +182,17 @@ test("plugin reads the current file for each connection metadata request", () => { type: "initialize", penpotUserSessionId: "tab-1", - session: { sessionId: "first", fileId: "file-1", fileName: "First design" }, + session: { sessionId: "first", fileId: "file-1", fileName: "First design", workspaceState: "ready" }, }, { type: "initialize", penpotUserSessionId: "tab-1", - session: { sessionId: "second", fileId: "file-2", fileName: "Second design" }, + session: { sessionId: "second", fileId: "file-2", fileName: "Second design", workspaceState: "ready" }, }, ]); }); -test("integrated connections reuse the short ID across reconnects", async () => { +test("integrated connections reuse the short session ID across reconnects", async () => { const ui = pluginUi(); ui.message({ type: "mcp-mode", integratedRemoteMcp: true }); const initialize = async (requestId: string) => { @@ -194,15 +203,18 @@ test("integrated connections reuse the short ID across reconnects", async () => }); }; await initialize(ui.connect()); - assert.equal(ui.sockets[0].sent[0].session.sessionId, "pq3gxqddgj"); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk"); ui.sockets[0].receive({ type: "initialized" }); - assert.equal(ui.messages.at(-1).sessionId, "pq3gxqddgj"); + assert.equal( + ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId, + "ftdmhltgwk" + ); ui.sockets[0].close(); [...ui.timers.values()][0](); ui.sockets[1].open(); const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId; await initialize(requestId); - assert.equal(ui.sockets[1].sent[0].session.sessionId, "pq3gxqddgj"); + assert.equal(ui.sockets[1].sent[0].session.sessionId, "ftdmhltgwk"); }); test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => { @@ -216,8 +228,9 @@ test("disconnect cancels metadata initialization and ignores a late acknowledgem }); ui.message({ type: "stop-server" }); await pending; - ui.sockets[0].receive({ type: "initialized" }); assert.deepEqual(ui.sockets[0].sent, []); + ui.sockets[0].receive({ type: "initialized" }); + assert.ok(!ui.messages.some((message) => message.status === "connected")); assert.equal(ui.messages.at(-1).status, "disconnected"); assert.equal(ui.timers.size, 0); }); @@ -243,6 +256,7 @@ test("integrated plugin stays idle until explicitly connected, including while t }, on() {}, }, + penpotMgmt: { on() {} }, mcp: { getToken: () => "test-token", getServerUrl: () => "ws://localhost:4402", @@ -280,7 +294,10 @@ test("standalone session ID survives reconnects and plugin restarts in the same }); assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId); ui.sockets[1].receive({ type: "initialized" }); - assert.equal(ui.messages.at(-1).sessionId, sessionId); + assert.equal( + ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId, + sessionId + ); const next = pluginUi(); const nextRequestId = next.connect(); @@ -291,3 +308,37 @@ test("standalone session ID survives reconnects and plugin restarts in the same }); assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId); }); + +test("file metadata does not affect the short session ID", async () => { + for (const fileId of [null, "file-1", "file-2"]) { + const ui = pluginUi(); + const requestId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: requestId, fileId, fileName: fileId }, + }); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk"); + } +}); + +test("dashboard sessions use the short session ID and publish context changes without reconnecting", async () => { + const ui = pluginUi(); + const requestId = ui.connect(); + await ui.message({ + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: requestId, fileId: null, fileName: null, workspaceState: "none" }, + }); + assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk"); + ui.sockets[0].receive({ type: "initialized" }); + assert.equal(ui.messages.at(-1).type, "context-request"); + for (const context of [ + { fileId: "file-1", fileName: "Design", workspaceState: "ready" }, + { fileId: null, fileName: null, workspaceState: "none" }, + ]) { + await ui.message({ type: "context-update", context }); + assert.deepEqual(ui.sockets[0].sent.at(-1), { type: "context-update", context }); + } + assert.equal(ui.sockets.length, 1); +}); diff --git a/mcp/packages/plugin/src/SessionId.test.ts b/mcp/packages/plugin/src/SessionId.test.ts index 8837fc7d2b..dbaa3ed162 100644 --- a/mcp/packages/plugin/src/SessionId.test.ts +++ b/mcp/packages/plugin/src/SessionId.test.ts @@ -2,13 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { SessionId } from "./SessionId.ts"; -test("derives a stable 50-bit Base32 identifier from the Penpot user session and file", async () => { - assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj"); - assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj"); - assert.equal(await SessionId.forFile("tab-2", "file-1"), "jablgbun4p"); - assert.equal(await SessionId.forFile("tab-1", "file-2"), "7s3kow4ucx"); +test("derives a stable 50-bit Base32 identifier from only the Penpot user session", async () => { + assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk"); + assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk"); + assert.equal(await SessionId.forSession("tab-2"), "cb3oexpdmd"); }); -test("encodes the Penpot user session and file without ambiguous boundaries", async () => { - assert.notEqual(await SessionId.forFile("ab", "c"), await SessionId.forFile("a", "bc")); +test("shortens a UUID session to ten copyable Base32 characters", async () => { + const sessionId = await SessionId.forSession("a7457b7c-cf45-80d4-8008-ba5b1d08e6c8"); + assert.match(sessionId, /^[a-z2-7]{10}$/); }); diff --git a/mcp/packages/plugin/src/SessionId.ts b/mcp/packages/plugin/src/SessionId.ts index e81f3e5a59..22e14b4309 100644 --- a/mcp/packages/plugin/src/SessionId.ts +++ b/mcp/packages/plugin/src/SessionId.ts @@ -2,8 +2,8 @@ export class SessionId { private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; - public static async forFile(penpotUserSessionId: string, fileId: string): Promise { - const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId])); + public static async forSession(penpotUserSessionId: string): Promise { + const input = new TextEncoder().encode(penpotUserSessionId); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); return SessionId.encode(digest); } diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index 1fa11567bb..67a498275f 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -8,6 +8,7 @@ interface McpOptions { declare global { const mcp: undefined | McpOptions; + const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt; } export {}; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 29e5c0a286..71973ade0b 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -249,6 +249,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { cancelReconnect(); startHeartbeat(); updateConnectionStatus("connected", "Connected"); + parent.postMessage({ type: "context-request" }, "*"); return; } // Track the current task received from the MCP server @@ -349,10 +350,7 @@ window.addEventListener("message", async (event) => { if (!initialization.penpotUserSessionId) { throw new Error("Missing Penpot user session ID"); } - const sessionId = await SessionId.forFile( - initialization.penpotUserSessionId, - initialization.session.fileId - ); + const sessionId = await SessionId.forSession(initialization.penpotUserSessionId); if ( !shouldReconnect || ws !== socket || @@ -377,6 +375,12 @@ window.addEventListener("message", async (event) => { } return; } + if (event.data.type === "context-update") { + if (connectionReady && ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event.data)); + } + return; + } if (event.data.type === "mcp-mode") { isIntegratedRemoteMcp = event.data.integratedRemoteMcp; } diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index eb01d2d571..bb55706a10 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -57,7 +57,7 @@ penpot.ui.onMessage< penpotVersion: penpotVersionPrefix, }); } - // connect only when requested in this workspace + // connect only when requested in this tab if (isIntegratedRemoteMcp && mcp?.isConnectionRequested()) { penpot.ui.sendMessage({ type: "start-server", @@ -66,15 +66,22 @@ penpot.ui.onMessage< }); } } else if (typeof message === "object" && message.type === "connection-metadata-request") { - const file = penpot.currentFile; - if (file && message.sessionId) { + const workspace = penpotMgmt.workspace; + if (message.sessionId) { const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = { type: "initialize", - session: { sessionId: message.sessionId, fileId: file.id, fileName: file.name }, + session: { + sessionId: message.sessionId, + fileId: workspace.fileId, + fileName: workspace.fileName, + workspaceState: workspace.status, + }, penpotUserSessionId: penpot.currentUser.sessionId, }; penpot.ui.sendMessage(initialization); } + } else if (typeof message === "object" && message.type === "context-request") { + sendWorkspaceContext(); } else if (typeof message === "object" && message.type === "update-connection-status") { mcp?.setMcpStatus(message.status || "unknown", message.sessionId); } else if (typeof message === "object" && message.task && message.id) { @@ -85,6 +92,21 @@ penpot.ui.onMessage< } }); +/** Sends the current workspace metadata without replacing the connection. */ +function sendWorkspaceContext(): void { + const workspace = penpotMgmt.workspace; + penpot.ui.sendMessage({ + type: "context-update", + context: { + fileId: workspace.fileId, + fileName: workspace.fileName, + workspaceState: workspace.status, + }, + }); +} + +penpotMgmt.on("workspacechange", sendWorkspaceContext); + /** * Handles plugin task requests received from the MCP server via WebSocket. * diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 87ea2c7616..2132446f80 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler { // initialize context, making penpot, penpotUtils, storage and the custom console available this.context = { penpot: penpot, + penpotMgmt: penpotMgmt, storage: {}, console: new ExecuteCodeTaskConsole(), penpotUtils: PenpotUtils, @@ -217,7 +218,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler { // wait for layout updates prior to executing the supplied code (if method is available) try { // @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types - if (penpot.waitForLayoutUpdate) { + if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) { // @ts-ignore await penpot.waitForLayoutUpdate(); } diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts index 1472353588..1a42241169 100644 --- a/mcp/packages/server/src/PluginBridge.sessions.test.ts +++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts @@ -143,3 +143,29 @@ test("invalid initialization metadata cannot register a session", async () => { assert.equal((await closed)[0], 1008); await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /No Penpot sessions/); }); + +test("a dashboard session remains routable while its workspace metadata changes", async () => { + const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(socket); + await once(socket, "open"); + const initialized = once(socket, "message"); + socket.send( + JSON.stringify({ + type: "initialize", + session: { sessionId: "tab", fileId: null, fileName: null, workspaceState: "none" }, + }) + ); + await initialized; + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + socket.send(JSON.stringify({ id: request.id, success: true, data: "ok" })); + }); + for (const context of [ + { fileId: "file-1", fileName: "Design", workspaceState: "ready" }, + { fileId: null, fileName: null, workspaceState: "none" }, + ]) { + socket.send(JSON.stringify({ type: "context-update", context })); + assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "ok"); + assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]); + } +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index f53ed3f849..913b28b26a 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -27,8 +27,9 @@ const connectionInitSchema = z.object({ type: z.literal("initialize"), session: z.object({ sessionId: z.string().min(1).max(128), - fileId: z.string().min(1).max(128), - fileName: z.string(), + fileId: z.string().min(1).max(128).nullable(), + fileName: z.string().nullable(), + workspaceState: z.enum(["none", "loading", "ready"]).optional(), }), }); @@ -170,6 +171,13 @@ export class PluginBridge implements TaskDispatchHost { return; } connection.frozen = false; + if (message?.type === "context-update") { + const context = connectionInitSchema.shape.session + .omit({ sessionId: true }) + .parse(message.context); + Object.assign(connection.session, context); + return; + } if (message?.type === "heartbeat") { return; } diff --git a/mcp/packages/server/src/dispatch/TaskDispatcher.ts b/mcp/packages/server/src/dispatch/TaskDispatcher.ts index b9fdcf17aa..062af6b80c 100644 --- a/mcp/packages/server/src/dispatch/TaskDispatcher.ts +++ b/mcp/packages/server/src/dispatch/TaskDispatcher.ts @@ -31,7 +31,7 @@ export abstract class TaskDispatcher { sessions .map( (session) => - `${session.sessionId}: ${JSON.stringify(session.fileName)} (file ${session.fileId})` + `${session.sessionId}: ${session.fileId ? `${JSON.stringify(session.fileName)} (file ${session.fileId})` : "no active workspace"}` ) .join("\n") ); diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index ee15401005..7f913a5700 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -49,8 +49,10 @@ export class ExecuteCodeTool extends Tool { "Executes JavaScript code in the Penpot plugin context.\n" + "IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " + "which Penpot API functionality is necessary and how to use it.\n" + - "You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " + - "and `storage`.\n" + + "You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" + + "`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " + + "to open a file and wait until it is ready; teamId defaults to the current team. " + + "File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\n" + "`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " + "stored attributes can be referenced in future calls to this tool, so any intermediate results that " + "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 4b45df68b0..8ff05e595b 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,8 @@ ### 🚀 Features +- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout. +- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index b7dbcc02fc..0ef43fdd4d 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4,7 +4,7 @@ */ export interface Penpot extends Omit< Context, - 'addListener' | 'removeListener' + 'addListener' | 'removeListener' | 'management' > { readonly ui: { /** @@ -778,10 +778,32 @@ export interface CommonLayout { remove(): void; } +/** Current workspace of this browser tab. */ +export interface WorkspaceContext { + readonly status: 'none' | 'loading' | 'ready'; + readonly fileId: string | null; + readonly fileName: string | null; + readonly teamId: string | null; +} + +/** Application management API available to global plugins. */ +export interface PenpotMgmt { + readonly workspace: WorkspaceContext; + /** Opens a file in this tab and resolves once its workspace and page are ready. */ + openFile(fileId: string, options?: { teamId?: string }): Promise; + /** Subscribes to workspace availability and metadata changes. */ + on( + type: 'workspacechange', + callback: (workspace: WorkspaceContext) => void, + ): symbol; + off(listenerId: symbol): void; +} + /** * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { + readonly management?: Pick; /** * Returns the current penpot version. */ @@ -1556,6 +1578,8 @@ export interface Ellipse extends ShapeBase { * ``` */ export interface EventsMap { + readonly workspacechange: WorkspaceContext; + readonly logout: void; /** * The `pagechange` event is triggered when the active page in the project is changed. */ @@ -5757,4 +5781,5 @@ export interface Viewport { declare global { const penpot: Penpot; + const penpotMgmt: PenpotMgmt; } diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 8956a5124e..a0ef062d3e 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -19,6 +19,7 @@ import type { Page, Path, Penpot, + PenpotMgmt, Rectangle, Shape, SvgRaw, @@ -36,6 +37,8 @@ import { createPluginManager } from '../plugin-manager.js'; export const validEvents = [ 'finish', + 'logout', + 'workspacechange', 'pagechange', 'filechange', 'selectionchange', @@ -47,10 +50,24 @@ export const validEvents = [ export function createApi( plugin: Awaited>, ) { - const checkPermission = (permission: Permissions) => { + const workspaceUnavailable = () => + plugin.manifest.scope === 'global' && + plugin.context.management?.workspace.status !== 'ready'; + + const requireWorkspace = () => { + if (workspaceUnavailable()) { + throw new Error( + 'No workspace is ready. Use penpotMgmt.openFile() first.', + ); + } + }; + + const checkPermission = (permission: Permissions, workspace = true) => { if (!plugin.manifest.permissions.includes(permission)) { throw new Error(`Permission ${permission} is not granted`); } + if (workspace && /^(content|library|comment):/.test(permission)) + requireWorkspace(); }; const penpot: Penpot = { @@ -162,7 +179,7 @@ export function createApi( z.function().parse(callback); // To suscribe to events needs the read permission - checkPermission('content:read'); + checkPermission('content:read', false); return plugin.registerListener(type, callback, props); }, @@ -178,18 +195,18 @@ export function createApi( }, get root(): Shape | null { - checkPermission('content:read'); - return plugin.context.root; + checkPermission('content:read', false); + return workspaceUnavailable() ? null : plugin.context.root; }, get currentFile(): File | null { - checkPermission('content:read'); - return plugin.context.currentFile; + checkPermission('content:read', false); + return workspaceUnavailable() ? null : plugin.context.currentFile; }, get currentPage(): Page | null { - checkPermission('content:read'); - return plugin.context.currentPage; + checkPermission('content:read', false); + return workspaceUnavailable() ? null : plugin.context.currentPage; }, get selection(): Shape[] { @@ -203,10 +220,12 @@ export function createApi( }, get viewport(): Viewport { + requireWorkspace(); return plugin.context.viewport; }, get history(): HistoryContext { + requireWorkspace(); return plugin.context.history; }, @@ -395,7 +414,28 @@ export function createApi( }, }; + const penpotMgmt: PenpotMgmt = { + get workspace() { + checkPermission('content:read', false); + return plugin.context.management!.workspace; + }, + openFile(fileId, options) { + checkPermission('content:read', false); + return plugin.context.management!.openFile(fileId, options); + }, + on(type, callback) { + z.literal('workspacechange').parse(type); + z.function().parse(callback); + checkPermission('content:read', false); + return plugin.registerListener(type, callback); + }, + off(listenerId) { + plugin.destroyListener(listenerId); + }, + }; + return { penpot, + penpotMgmt, }; } diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts index 97cc4bb123..45af8b7621 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.spec.ts @@ -33,6 +33,7 @@ describe('createSandbox', () => { beforeEach(() => { mockPlugin = { + manifest: { scope: 'workspace' }, code: 'console.log("Plugin running");', timeouts: new Set>(), } as unknown as Awaited>; diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts index 292ae49183..c8aaf3c40a 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts @@ -1,4 +1,3 @@ -import type { Penpot } from '@penpot/plugin-types'; import type { createPluginManager } from './plugin-manager'; import { createApi } from './api'; import { ses } from './ses.js'; @@ -67,7 +66,7 @@ export function createSandbox( const pluginApi = createApi(plugin); const safeHandler = { - get(target: Penpot, prop: string, receiver: unknown) { + get(target: object, prop: string, receiver: unknown) { const originalValue = Reflect.get(target, prop, receiver); if (typeof originalValue === 'function') { @@ -202,6 +201,12 @@ export function createSandbox( publicPluginApi = Object.assign(publicPluginApi, apiExtensions); } + if (plugin.manifest.scope === 'global') { + Object.assign(publicPluginApi, { + penpotMgmt: new Proxy(pluginApi.penpotMgmt, safeHandler), + }); + } + const compartment = ses.createCompartment(publicPluginApi); return { diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts index 06ef7b20b7..4151e09fea 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts @@ -98,9 +98,14 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { ); // The plugin code really ran inside the sandbox: the manager registers - // `themechange` + `finish`, and the plugin code adds its own `finish` + // `themechange` + `finish` + `logout`, and the plugin code adds its own `finish` // listener through the public API. - expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']); + expect(fixture.listenerTypes).toEqual([ + 'themechange', + 'finish', + 'logout', + 'finish', + ]); expect(getPlugins()).toHaveLength(1); // The user-facing behavior from #11001: host-side augmentation of a @@ -112,7 +117,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(() => { freshWrapper.toString = () => 'patched-by-runtime'; }).not.toThrow(); - expect(fixture.listenerTypes.length).toBe(3); + expect(fixture.listenerTypes.length).toBe(4); }); it('denies the write API without permission and leaves the host untouched', async () => { @@ -166,4 +171,79 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(fixture.createRectangle).toHaveBeenCalledTimes(1); expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true); }); + it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => { + const fixture = makeHostFixture(); + let workspace = { + status: 'none', + fileId: null, + fileName: null, + teamId: null, + }; + const listeners = new Map< + symbol, + { type: string; callback: (value?: unknown) => void } + >(); + const context = { + ...fixture.context, + management: { + get workspace() { + return workspace; + }, + openFile: async () => {}, + }, + addListener(type: string, callback: (value?: unknown) => void) { + const id = Symbol(); + listeners.set(id, { type, callback }); + return id; + }, + removeListener(id: symbol) { + listeners.delete(id); + }, + } as unknown as Context; + setContextBuilder(() => context); + const manifest = { + ...makeManifest( + 'globalThis.savedPenpot = penpot; globalThis.probe = penpotMgmt.workspace.status;', + ['content:read', 'content:write'], + ), + pluginId: 'global-plugin', + scope: 'global' as const, + }; + await loadPlugin(manifest); + const globalPlugin = getPlugins().find( + (plugin) => plugin.manifest.pluginId === 'global-plugin', + )!; + const globals = globalPlugin.compartment.compartment.globalThis; + expect(globals['probe']).toBe('none'); + expect(() => + globalPlugin.compartment.compartment.evaluate('penpot.createRectangle()'), + ).toThrow(/No workspace/); + for (const listener of [...listeners.values()]) { + if (listener.type === 'finish') listener.callback(); + } + await loadPlugin(makeManifest('globalThis.workspacePlugin = true;', [])); + expect(getPlugins()).toContain(globalPlugin); + await loadPlugin(manifest); + expect( + getPlugins().filter( + (plugin) => plugin.manifest.pluginId === 'global-plugin', + ), + ).toHaveLength(1); + workspace = { ...workspace, status: 'ready' }; + globalPlugin.compartment.compartment.evaluate( + 'penpot.createRectangle(); globalThis.sameFacade = savedPenpot === penpot;', + ); + expect(globals['sameFacade']).toBe(true); + expect(fixture.createRectangle).toHaveBeenCalledOnce(); + await expect( + loadPlugin(makeManifest('throw new Error("failure");', [])), + ).rejects.toThrow('failure'); + expect(getPlugins()).toContain(globalPlugin); + for (const listener of [...listeners.values()]) { + if (listener.type === 'logout') listener.callback(); + } + expect(getPlugins()).toHaveLength(0); + expect(globals['penpotMgmt']).toBeUndefined(); + expect(listeners.size).toBe(0); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index e22e62c70c..be332d9058 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -17,20 +17,19 @@ export function setContextBuilder(builder: ContextBuilder) { export const getPlugins = () => plugins; const closeAllPlugins = () => { - // Background plugins keep running, so they must stay registered: the - // registry routes their UI messages and lets them be unloaded later. - const backgroundPlugins: typeof plugins = []; - - plugins.forEach((pluginApi) => { + // Background and global plugins keep running, so they must stay registered: + // the registry routes their UI messages and lets them be unloaded later. + plugins = plugins.filter((pluginApi) => { /* eslint-disable @typescript-eslint/no-explicit-any */ - if ((pluginApi.manifest as any)?.allowBackground) { - backgroundPlugins.push(pluginApi); - } else { - pluginApi.plugin.close(); + if ( + pluginApi.manifest?.scope === 'global' || + (pluginApi.manifest as any)?.allowBackground + ) { + return true; } + pluginApi.plugin.close(); + return false; }); - - plugins = backgroundPlugins; }; window.addEventListener('message', (event) => { @@ -57,7 +56,17 @@ export const loadPlugin = async function ( return; } - closeAllPlugins(); + if (manifest.scope === 'global') { + if ( + plugins.some( + (plugin) => plugin.manifest?.pluginId === manifest.pluginId, + ) + ) { + return; + } + } else { + closeAllPlugins(); + } // The host context is not deeply frozen at this load stage. // @@ -74,7 +83,8 @@ export const loadPlugin = async function ( // `createSandbox`'s proxy handler applies `ses.safeReturn` to values // crossing into the sandbox. Compartment isolation and intrinsics // hardening are performed by createSandbox, not here. - const plugin = await createPlugin( + let plugin: Awaited> | undefined = undefined; + plugin = await createPlugin( context, manifest, () => { @@ -88,7 +98,7 @@ export const loadPlugin = async function ( ); plugins.push(plugin); } catch (error) { - closeAllPlugins(); + if (manifest.scope !== 'global') closeAllPlugins(); throw error; } }; diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index 16d4fd5e28..aefc563c65 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -7,6 +7,7 @@ export const manifestSchema = z.object({ code: z.string(), icon: z.string().optional(), version: z.number().optional(), + scope: z.enum(['workspace', 'global']).optional(), description: z.string().max(200).optional(), permissions: z.array( z.enum([ diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 7878a1abcd..f430321210 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -40,11 +40,10 @@ export async function createPluginManager( modal?.setTheme(theme); }); - const listenerId: symbol = context.addListener('finish', () => { - closePlugin(); - - context?.removeListener(listenerId); + const listenerId = context.addListener('finish', () => { + if (manifest.scope !== 'global') closePlugin(); }); + const logoutId = context.addListener('logout', () => closePlugin()); let listeners: symbol[] = []; @@ -60,7 +59,11 @@ export async function createPluginManager( }; const closePlugin = () => { + if (destroyed) return; + destroyed = true; removeAllEventListeners(); + destroyListener(listenerId); + destroyListener(logoutId); timeouts.forEach(clearTimeout); timeouts.clear(); @@ -74,8 +77,6 @@ export async function createPluginManager( modal = null; } - destroyed = true; - onCloseCallback(); }; From 54cce66c13e569a5870d6bb922b6e6a09bf2a267 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 13:19:11 +0000 Subject: [PATCH 056/325] :sparkles: Share the MCP connection menu with the dashboard Connection status: - Replace the dashboard's action-only link with an explicit connected label and indicator, using the theme's existing accent colour. - Reflect the actual connection state independently of connection intent. Shared controls: - Extract the toolbar's MCP menu and styles into a shared component used by both the dashboard and workspace. - Provide connection status, session ID, copy, connect and disconnect actions in both locations, preserving hover and keyboard behaviour. - Keep dashboard and toolbar event origins distinct and update the dashboard connection instructions. Validation: - Confirm dashboard disconnect/reconnect, menu opening, keyboard navigation and Escape dismissal in the running app. - Pass live compilation, targeted ClojureScript and SCSS lint, formatting checks and git diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- docs/mcp/index.md | 2 +- .../src/app/main/ui/components/mcp_menu.cljs | 141 ++++++++++++++++++ .../src/app/main/ui/components/mcp_menu.scss | 127 ++++++++++++++++ .../src/app/main/ui/dashboard/sidebar.cljs | 22 +-- .../app/main/ui/workspace/top_toolbar.cljs | 120 +-------------- .../app/main/ui/workspace/top_toolbar.scss | 106 ------------- 6 files changed, 276 insertions(+), 242 deletions(-) create mode 100644 frontend/src/app/main/ui/components/mcp_menu.cljs create mode 100644 frontend/src/app/main/ui/components/mcp_menu.scss diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 47a4c55803..cce812b516 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -81,7 +81,7 @@ Some important concepts for users: Connect the tabs you want to use: -* **Remote MCP**: choose **MCP: Connect** in the dashboard sidebar or **MCP → Connect** in a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. +* **Remote MCP**: choose **MCP → Connect** in the dashboard sidebar or a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. * **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. * Connecting or disconnecting one tab does not disconnect the others. * A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file. diff --git a/frontend/src/app/main/ui/components/mcp_menu.cljs b/frontend/src/app/main/ui/components/mcp_menu.cljs new file mode 100644 index 0000000000..f38e4f377b --- /dev/null +++ b/frontend/src/app/main/ui/components/mcp_menu.cljs @@ -0,0 +1,141 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.components.mcp-menu + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] + [app.main.data.workspace.mcp :as mcp] + [app.main.store :as st] + [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.util.clipboard :as clipboard] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [rumext.v2 :as mf])) + +(defn- cancel-timer! + [timer-ref*] + (when-let [timer (mf/ref-val timer-ref*)] + (ts/dispose! timer) + (mf/set-ref-val! timer-ref* nil))) + +(mf/defc mcp-menu* + {::mf/wrap [mf/memo]} + [{:keys [is-mcp-connected is-connection-requested session-id dashboard]}] + (let [copied-text (tr "workspace.toolbar.mcp-session-copied") + copy-error (tr "errors.clipboard-api-unavailable") + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + on-open-menu + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* true))) + + on-close-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* false))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! menu-open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! menu-open* false) + (mf/set-ref-val! close-timer* nil)))))) + + on-connect + (mf/use-fn + (mf/deps dashboard) + #(st/emit! (mcp/connect-mcp) + (ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin (if dashboard "dashboard:sidebar" "workspace:toolbar")}))) + + on-disconnect + (mf/use-fn + #(st/emit! (mcp/user-disconnect-mcp))) + + on-copy-session + (mf/use-fn + (mf/deps session-id copied-text copy-error) + (fn [] + (-> (clipboard/to-clipboard session-id) + (.then #(st/emit! (ntf/info copied-text))) + (.catch #(st/emit! (ntf/error copy-error))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:class (stl/css-case :mcp-tool true :dashboard dashboard) + :on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} + [:> button* {:variant "ghost" + :on-click on-open-menu + :aria-haspopup true + :aria-expanded menu-open? + :aria-pressed menu-open? + :data-tool "mcp" + :data-testid "mcp-btn"} + [:div {:class (stl/css-case :toolbar-mcp-button true + :selected menu-open?)} + [:span {:class (stl/css-case :toolbar-mcp-button-dot true + :connected is-mcp-connected)}] + [:span {:class (stl/css-case :toolbar-mcp-button-label true + :connected is-mcp-connected)} + (if (and dashboard is-mcp-connected) + (tr "workspace.toolbar.mcp-connected") + (tr "workspace.toolbar.mcp"))]]] + + [:div {:class (stl/css :toolbar-mcp-menu)} + [:> dropdown-menu* {:show menu-open? + :on-close on-close-menu + :class (stl/css :toolbar-mcp-dropdown)} + (when (or is-mcp-connected session-id) + [:li {:class (stl/css :toolbar-mcp-dropdown-info) + :role "presentation"} + (when is-mcp-connected + [:span (tr "workspace.toolbar.mcp-connected")]) + (when session-id + [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) + (when session-id + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click on-copy-session} + (tr "workspace.toolbar.mcp-copy-session-id")]) + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click (if is-connection-requested on-disconnect on-connect)} + (if is-connection-requested + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) diff --git a/frontend/src/app/main/ui/components/mcp_menu.scss b/frontend/src/app/main/ui/components/mcp_menu.scss new file mode 100644 index 0000000000..f4c69ba4c8 --- /dev/null +++ b/frontend/src/app/main/ui/components/mcp_menu.scss @@ -0,0 +1,127 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; + +.mcp-tool { + position: relative; +} + +.dashboard { + margin-inline: var(--sp-l); +} + +.toolbar-mcp-button { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.toolbar-mcp-button-label { + &.connected { + color: var(--color-accent-primary); + } +} + +.toolbar-mcp-button-dot { + position: relative; + display: flex; + flex-shrink: 0; + inline-size: px2rem(6); + block-size: px2rem(6); + border-radius: 50%; + background-color: var(--color-background-disabled); + + &.connected { + background-color: var(--color-accent-primary); + } + + // One-shot "blob" ripple that confirms the moment the tab becomes + // connected (e.g. after using "Switch here"). Triggered purely by the + // `.connected` class appearing, in sync with the color change. + &.connected::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--color-accent-primary); + opacity: 0; + animation: mcp-status-blob 0.5s ease-out; + } +} + +@keyframes mcp-status-blob { + from { + opacity: 0.5; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(2.5); + } +} + +.toolbar-mcp-menu { + position: absolute; + left: 0; + top: $sz-36; +} + +// Same look as the workspace main menu (`.base-menu` in main_menu.scss). +// The toolbar overrides `--menu-background-color`, so restore the global +// menu value here. +.toolbar-mcp-dropdown { + --menu-background-color: var(--color-background-tertiary); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); + z-index: var(--z-index-dropdown); + margin: 0; + padding: var(--sp-xs); + border: $b-2 solid var(--panel-border-color); + border-radius: $br-8; + background-color: var(--menu-background-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); +} + +.toolbar-mcp-dropdown-info { + @include t.use-typography("body-small"); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); + font-size: px2rem(11); + white-space: nowrap; +} + +.toolbar-mcp-dropdown-item { + @include t.use-typography("body-small"); + + cursor: pointer; + display: flex; + align-items: center; + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + color: var(--menu-foreground-color); + background-color: var(--menu-background-color); + white-space: nowrap; + + &:hover { + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); + } +} diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 386d1dc791..8787c290ca 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -17,13 +17,13 @@ [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] - [app.main.data.workspace.mcp :as mcp] [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] + [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -257,12 +257,6 @@ team-id (get team :id) mcp-state (mf/deref refs/mcp) - mcp-requested? (:connection-requested mcp-state) - toggle-mcp (mf/use-fn - (mf/deps mcp-requested?) - #(st/emit! (if mcp-requested? - (mcp/user-disconnect-mcp) - (mcp/connect-mcp)))) projects? (= section :dashboard-recent) fonts? (= section :dashboard-fonts) @@ -394,16 +388,10 @@ (:enabled mcp-state) (:token-valid mcp-state)) [:div {:class (stl/css :sidebar-content-section)} - [:ul {:class (stl/css :sidebar-nav)} - [:li {:class (stl/css :sidebar-nav-item)} - [:> link* {:action toggle-mcp - :keyboard-action toggle-mcp - :class (stl/css :sidebar-link)} - [:span {:class (stl/css :element-title)} - "MCP: " - (if mcp-requested? - (tr "workspace.header.menu.mcp.plugin.status.disconnect") - (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]]) + [:> mcp-menu* {:dashboard true + :is-mcp-connected (= "connected" (:connection-status mcp-state)) + :is-connection-requested (:connection-requested mcp-state) + :session-id (:session-id mcp-state)}]]) [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 0a10cf095d..7a7f767621 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -12,24 +12,20 @@ [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.drawing.common :as dwdc] - [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path.state :as pst] [app.main.data.workspace.shortcuts :as sc] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.file-uploader :as file-uploader] + [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.context :as ctx] - [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -297,118 +293,6 @@ :ref ref :on-selected on-selected}]])) -(mf/defc mcp-tool* - {::mf/private true - ::mf/wrap [mf/memo]} - [{:keys [is-mcp-connected is-connection-requested session-id]}] - (let [copied-text (tr "workspace.toolbar.mcp-session-copied") - copy-error (tr "errors.clipboard-api-unavailable") - menu-open* (mf/use-state false) - menu-open? (deref menu-open*) - - open-timer* (mf/use-ref nil) - close-timer* (mf/use-ref nil) - - on-open-menu - (mf/use-fn - (fn [event] - (dom/stop-propagation event) - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (reset! menu-open* true))) - - on-close-menu - (mf/use-fn - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (reset! menu-open* false))) - - on-display-menu - (mf/use-fn - (fn [] - (cancel-timer! close-timer*) - (cancel-timer! open-timer*) - (mf/set-ref-val! - open-timer* - (ts/schedule 350 - #(do - (reset! menu-open* true) - (mf/set-ref-val! open-timer* nil)))))) - - on-hide-menu - (mf/use-fn - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*) - (mf/set-ref-val! - close-timer* - (ts/schedule 350 - #(do - (reset! menu-open* false) - (mf/set-ref-val! close-timer* nil)))))) - - on-connect - (mf/use-fn - #(st/emit! (mcp/connect-mcp) - (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace:toolbar"}))) - - on-disconnect - (mf/use-fn - #(st/emit! (mcp/user-disconnect-mcp))) - - on-copy-session - (mf/use-fn - (mf/deps session-id copied-text copy-error) - (fn [] - (-> (clipboard/to-clipboard session-id) - (.then #(st/emit! (ntf/info copied-text))) - (.catch #(st/emit! (ntf/error copy-error))))))] - - (mf/with-effect [] - (fn [] - (cancel-timer! open-timer*) - (cancel-timer! close-timer*))) - - [:div {:on-pointer-enter on-display-menu - :on-pointer-leave on-hide-menu} - [:> button* {:variant "ghost" - :on-click on-open-menu - :aria-haspopup true - :aria-expanded menu-open? - :aria-pressed menu-open? - :data-tool "mcp" - :data-testid "mcp-btn"} - [:div {:class (stl/css-case :toolbar-mcp-button true - :selected menu-open?)} - [:span {:class (stl/css-case :toolbar-mcp-button-dot true - :connected is-mcp-connected)}] - [:span {:class (stl/css-case :toolbar-mcp-button-label true - :connected is-mcp-connected)} - (tr "workspace.toolbar.mcp")]]] - - [:div {:class (stl/css :toolbar-mcp-menu)} - [:> dropdown-menu* {:show menu-open? - :on-close on-close-menu - :class (stl/css :toolbar-mcp-dropdown)} - (when (or is-mcp-connected session-id) - [:li {:class (stl/css :toolbar-mcp-dropdown-info) - :role "presentation"} - (when is-mcp-connected - [:span (tr "workspace.toolbar.mcp-connected")]) - (when session-id - [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) - (when session-id - [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click on-copy-session} - (tr "workspace.toolbar.mcp-copy-session-id")]) - [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click (if is-connection-requested on-disconnect on-connect)} - (if is-connection-requested - (tr "workspace.header.menu.mcp.plugin.status.disconnect") - (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) - (mf/defc top-toolbar* {::mf/wrap [mf/memo]} [{:keys [layout]}] @@ -545,7 +429,7 @@ (when mcp-show? [:li {:class (stl/css :toolbar-option)} - [:> mcp-tool* {:is-mcp-connected mcp-connected? + [:> mcp-menu* {:is-mcp-connected mcp-connected? :is-connection-requested (:connection-requested mcp) :session-id (:session-id mcp)}]])] diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 7f5637c8f5..71ad771b3c 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -101,112 +101,6 @@ } } -.toolbar-mcp-button { - display: flex; - align-items: center; - gap: var(--sp-xs); -} - -.toolbar-mcp-button-label { - &.connected { - color: var(--color-accent-primary); - } -} - -.toolbar-mcp-button-dot { - position: relative; - display: flex; - flex-shrink: 0; - inline-size: px2rem(6); - block-size: px2rem(6); - border-radius: 50%; - background-color: var(--color-background-disabled); - - &.connected { - background-color: var(--color-accent-primary); - } - - // One-shot "blob" ripple that confirms the moment the tab becomes - // connected (e.g. after using "Switch here"). Triggered purely by the - // `.connected` class appearing, in sync with the color change. - &.connected::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 50%; - background-color: var(--color-accent-primary); - opacity: 0; - animation: mcp-status-blob 0.5s ease-out; - } -} - -@keyframes mcp-status-blob { - from { - opacity: 0.5; - transform: scale(1); - } - - to { - opacity: 0; - transform: scale(2.5); - } -} - -.toolbar-mcp-menu { - position: absolute; - left: 0; - top: $sz-36; -} - -// Same look as the workspace main menu (`.base-menu` in main_menu.scss). -// The toolbar overrides `--menu-background-color`, so restore the global -// menu value here. -.toolbar-mcp-dropdown { - --menu-background-color: var(--color-background-tertiary); - - display: flex; - flex-direction: column; - gap: var(--sp-xs); - z-index: var(--z-index-dropdown); - margin: 0; - padding: var(--sp-xs); - border: $b-2 solid var(--panel-border-color); - border-radius: $br-8; - background-color: var(--menu-background-color); - box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); -} - -.toolbar-mcp-dropdown-info { - @include t.use-typography("body-small"); - - display: flex; - flex-direction: column; - gap: var(--sp-xs); - padding: var(--sp-s) var(--sp-m); - border-radius: $br-8; - color: var(--color-foreground-secondary); - background-color: var(--color-background-primary); - font-size: px2rem(11); - white-space: nowrap; -} - -.toolbar-mcp-dropdown-item { - @include t.use-typography("body-small"); - - display: flex; - align-items: center; - padding: var(--sp-s) var(--sp-m); - border-radius: $br-8; - color: var(--menu-foreground-color); - background-color: var(--menu-background-color); - white-space: nowrap; - - &:hover { - --menu-foreground-color: var(--menu-foreground-color-hover); - --menu-background-color: var(--menu-background-color-hover); - } -} - .toolbar-separator { --separator-color: var(--color-background-quaternary); From 2bb6cd557eff7b2c457a96a42e43da7d9ee4f8c3 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 14:32:56 +0000 Subject: [PATCH 057/325] :sparkles: Add project and file discovery to penpotMgmt Discovery: - Add listProjects and project-scoped listFiles with content:read checks. - Return metadata from existing RPCs in dashboard and workspace contexts. - Reject invalid IDs and preserve backend access errors. Documentation: - Keep usage in API type comments and include regenerated MCP API docs. - Remove README implementation details and consolidate the changelog. - Add usage information to the MCP server's initial instructions Validation: - Pass 8 frontend tests, 81 runtime tests and the runtime build. - Verify both APIs through live MCP calls in dashboard and workspace. - Pass type, lint, formatting and diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- frontend/src/app/plugins/management.cljs | 48 ++- .../plugins/management_test.cljs | 71 +++++ mcp/README.md | 10 - mcp/packages/server/data/api_types.yml | 273 +++++++++++++++++- .../server/data/initial_instructions.md | 10 +- .../server/src/tools/ExecuteCodeTool.ts | 5 +- plugins/CHANGELOG.md | 3 +- plugins/libs/plugin-types/index.d.ts | 73 ++++- .../libs/plugins-runtime/src/lib/api/index.ts | 8 + .../src/lib/api/plugin-api.spec.ts | 54 ++++ 10 files changed, 532 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index bff5d9292d..9dadac543b 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -8,9 +8,11 @@ (:require [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] + [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [beicon.v2.core :as rx])) (defn workspace-context [state] @@ -70,9 +72,51 @@ (finish (js/Error. "File navigation was superseded")))))) (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) +(defn list-projects + [options] + (let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (if (nil? team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")) + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (clj->js + (mapv (fn [project] + {:id (str (:id project)) + :teamId (str (:team-id project)) + :name (:name project) + :isDefault (boolean (:is-default project)) + :fileCount (:count project)}) + (remove :deleted-at projects))))) + (rx/subs! resolve reject))))))) + +(defn list-files + [options] + (let [project-id (uuid/parse* (obj/get options "projectId"))] + (if (nil? project-id) + (js/Promise.reject (js/Error. "Expected a project UUID")) + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :get-project-files {:project-id project-id}) + (rx/map (fn [files] + (clj->js + (mapv (fn [file] + {:id (str (:id file)) + :teamId (str (:team-id file)) + :projectId (str (:project-id file)) + :name (:name file) + :modifiedAt (.toISOString ^js (:modified-at file))}) + files)))) + (rx/subs! resolve reject))))))) + (defn create-context [] (obj/reify {:name "PenpotManagementContext"} :workspace {:get (fn [] (clj->js (workspace-context @st/state)))} - :openFile open-file)) + :openFile open-file + :listProjects list-projects + :listFiles list-files)) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 1e11a83367..9db0ae80c0 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -7,9 +7,11 @@ (ns frontend-tests.plugins.management-test (:require [app.common.uuid :as uuid] + [app.main.repo :as rp] [app.main.store :as st] [app.plugins.events :as events] [app.plugins.management :as management] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] [frontend-tests.helpers.mock :as mock])) @@ -81,3 +83,72 @@ (let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)}) (.then (fn [] nil) (fn [error] (.-message error))))] (t/is (= "Expected a file UUID and a team UUID" (await result))))) + +(t/deftest ^:async list-projects-returns-active-metadata + (let [project-id (uuid/next) + other-team-id (uuid/next)] + (doseq [[state options expected-team-id] + [[dashboard nil team-id] + [ready #js {:teamId (str other-team-id)} other-team-id]]] + (let [state* (atom state)] + (await + (mock/with-mocks* + {st/state state* + rp/cmd! (mock/stub + (fn [command params] + (t/is (= [:get-projects {:team-id expected-team-id}] + [command params])) + (rx/of [{:id project-id :team-id expected-team-id + :name "Drafts" :is-default true :count 2 :total-count 3} + {:id (uuid/next) :deleted-at (js/Date.)}])))} + (let [projects (await (management/list-projects options))] + (t/is (= [{"id" (str project-id) "teamId" (str expected-team-id) + "name" "Drafts" "isDefault" true "fileCount" 2}] + (js->clj projects))) + (t/is (= state @state*))))))))) + +(t/deftest ^:async list-files-returns-metadata-without-opening-a-file + (let [project-id (uuid/next)] + (doseq [state [dashboard ready]] + (let [state* (atom state)] + (await + (mock/with-mocks* + {st/state state* + rp/cmd! (mock/stub + (fn [command params] + (t/is (= [:get-project-files {:project-id project-id}] + [command params])) + (rx/of [{:id file-id :team-id team-id :project-id project-id + :name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z") + :revn 10}])))} + (let [files (await (management/list-files #js {:projectId (str project-id)}))] + (t/is (= [{"id" (str file-id) "teamId" (str team-id) + "projectId" (str project-id) "name" "Design" + "modifiedAt" "2026-01-02T03:04:05.000Z"}] + (js->clj files))) + (t/is (= state @state*))))))))) + +(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying + (await + (mock/with-mocks* + {st/state (atom {}) + rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))} + (doseq [[result message] + [[(management/list-projects nil) "Expected a team UUID"] + [(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"] + [(management/list-files nil) "Expected a project UUID"] + [(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]] + (t/is (= message (await (.then result (fn [] nil) #(.-message %))))))))) + +(t/deftest ^:async listing-preserves-empty-results-and-backend-errors + (doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)})) + (fn [] (management/list-files #js {:projectId (str (uuid/next))}))]] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of [])))} + (t/is (= [] (js->clj (await (list-items))))))) + (let [error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} + (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) diff --git a/mcp/README.md b/mcp/README.md index 7018537793..59d60aaf61 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -33,16 +33,6 @@ This repository thus contains not only the MCP server implementation itself but also the supporting Penpot MCP Plugin (see section [Repository Structure](#repository-structure) below). -## Global plugin lifecycle - -- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden. -- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it. -- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`. -- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`. -- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise. -- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation. -- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission. - ## Demonstration [![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index e0ebcde698..c926147938 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -92,7 +92,7 @@ Penpot: Hierarchy - * Omit + * Omit + Penpot members: Properties: @@ -5001,6 +5001,250 @@ CommonLayout: The `remove` method removes the layout. + Returns void +WorkspaceContext: + overview: |- + Interface WorkspaceContext + ========================== + + Current workspace of this browser tab. + + ``` + interface WorkspaceContext { + status: "none" | "loading" | "ready"; + fileId: string | null; + fileName: string | null; + teamId: string | null; + } + ``` + + Referenced by: EventsMap, PenpotMgmt + members: + Properties: + status: |- + ``` + readonly status: "none" | "loading" | "ready" + ``` + fileId: |- + ``` + readonly fileId: string | null + ``` + fileName: |- + ``` + readonly fileName: string | null + ``` + teamId: |- + ``` + readonly teamId: string | null + ``` +ProjectSummary: + overview: |- + Interface ProjectSummary + ======================== + + Metadata snapshot of an accessible, non-deleted project. + + ``` + interface ProjectSummary { + id: string; + teamId: string; + name: string; + isDefault: boolean; + fileCount: number; + } + ``` + + Referenced by: PenpotMgmt + members: + Properties: + id: |- + ``` + readonly id: string + ``` + teamId: |- + ``` + readonly teamId: string + ``` + name: |- + ``` + readonly name: string + ``` + isDefault: |- + ``` + readonly isDefault: boolean + ``` + + Whether this is the team's Drafts project. + fileCount: |- + ``` + readonly fileCount: number + ``` +FileSummary: + overview: |- + Interface FileSummary + ===================== + + Metadata snapshot of an accessible, non-deleted file. + + ``` + interface FileSummary { + id: string; + teamId: string; + projectId: string; + name: string; + modifiedAt: string; + } + ``` + + Referenced by: PenpotMgmt + members: + Properties: + id: |- + ``` + readonly id: string + ``` + teamId: |- + ``` + readonly teamId: string + ``` + projectId: |- + ``` + readonly projectId: string + ``` + name: |- + ``` + readonly name: string + ``` + modifiedAt: |- + ``` + readonly modifiedAt: string + ``` + + Last modification time as an ISO 8601 string in UTC. +PenpotMgmt: + overview: |- + Interface PenpotMgmt + ==================== + + Application management API exposed as `penpotMgmt` to global plugins. + + * Requires `content:read` permission. + * Available in the dashboard and file workspaces. + * Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * Reacquire file, page and shape objects after changing files. + + ``` + interface PenpotMgmt { + workspace: WorkspaceContext; + listProjects(options?: { teamId?: string }): Promise; + listFiles(options: { projectId: string }): Promise; + openFile(fileId: string, options?: { teamId?: string }): Promise; + on( + type: "workspacechange", + callback: (workspace: WorkspaceContext) => void, + ): symbol; + off(listenerId: symbol): void; + } + ``` + + Referenced by: Context + members: + Properties: + workspace: |- + ``` + readonly workspace: WorkspaceContext + ``` + + Current workspace availability and metadata; usable for file operations when status is `ready`. + Methods: + listProjects: |- + ``` + listProjects(options?: { teamId?: string }): Promise + ``` + + Lists accessible, non-deleted projects as metadata snapshots. + + * `teamId` defaults to the current team. + * Queries the backend without opening a file or changing navigation. + * Rejects invalid identifiers and backend access errors. + + Parameters + + * options: { teamId?: string } + + Returns Promise + + Example + ``` + const projects = await penpotMgmt.listProjects(); + ``` + listFiles: |- + ``` + listFiles(options: { projectId: string }): Promise + ``` + + Lists accessible, non-deleted files in the specified project as metadata snapshots. + + * Requires an explicit `projectId`. + * Queries the backend without opening a file or changing navigation. + * Rejects invalid identifiers and backend access errors. + + Parameters + + * options: { projectId: string } + + Returns Promise + + Example + ``` + const files = await penpotMgmt.listFiles({ projectId: project.id }); + ``` + openFile: |- + ``` + openFile(fileId: string, options?: { teamId?: string }): Promise + ``` + + Opens a file in this tab and resolves once its workspace and page are ready. + + * `teamId` defaults to the current team. + * Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds. + + Parameters + + * fileId: string + * options: { teamId?: string } + + Returns Promise + + Example + ``` + await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + ``` + on: |- + ``` + on( + type: "workspacechange", + callback: (workspace: WorkspaceContext) => void, + ): symbol + ``` + + Subscribes to workspace availability and metadata changes. + + Parameters + + * type: "workspacechange" + * callback: (workspace: WorkspaceContext) => void + + Returns symbol + off: |- + ``` + off(listenerId: symbol): void + ``` + + Parameters + + * listenerId: symbol + Returns void Context: overview: |- @@ -5011,6 +5255,13 @@ Context: ``` interface Context { + management?: Pick< + PenpotMgmt, + "workspace" + | "openFile" + | "listProjects" + | "listFiles", + >; version: string; root: Shape | null; currentFile: File | null; @@ -5082,6 +5333,16 @@ Context: ``` members: Properties: + management: |- + ``` + readonly management?: Pick< + PenpotMgmt, + "workspace" + | "openFile" + | "listProjects" + | "listFiles", + > + ``` version: |- ``` readonly version: string @@ -7109,6 +7370,8 @@ EventsMap: ``` interface EventsMap { + workspacechange: WorkspaceContext; + logout: void; pagechange: Page; filechange: File; selectionchange: string[]; @@ -7122,6 +7385,14 @@ EventsMap: Referenced by: Context, Penpot members: Properties: + workspacechange: |- + ``` + readonly workspacechange: WorkspaceContext + ``` + logout: |- + ``` + readonly logout: void + ``` pagechange: |- ``` readonly pagechange: Page diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index be9177619c..ea5961c605 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -166,13 +166,15 @@ Boards can have layout systems that automatically control the positioning and sp # The `penpot` and `penpotUtils` Objects, Exploring Designs -A key object to use in your code is the `penpot` object (which is of type `Penpot`): +A key object for navigating the currently active Penpot file is the `penpot` object (which is of type `Penpot`): * `penpot.selection` provides the list of shapes the user has selected in the Penpot UI. If it is unclear which elements to work on, you can ask the user to select them for you. ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged. * `penpot.root` provides the root shape of the currently active page. * Generation of CSS content for elements via `penpot.generateStyle` * Generation of HTML/SVG content for elements via `penpot.generateMarkup` +Members of `penpot` (e.g. `penpot.currentFile` and `penpot.root`) will be null no Penpot file is currently opened. +You can use `penpotMgmt` to navigate to a file if needed (see below). For example, to generate CSS for the currently selected elements, you can execute this: return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true }); @@ -430,5 +432,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u * The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. Consider converting boards to flex layout when appropriate. +# The `penpotMgmt` Object + +This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. +It allows you to discover and navigate to different design files the user has access to. +You navigate to different design files only when your task explicitly requires it/you are instructed to do so. + -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index 7f913a5700..7a8e710e33 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -49,10 +49,7 @@ export class ExecuteCodeTool extends Tool { "Executes JavaScript code in the Penpot plugin context.\n" + "IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " + "which Penpot API functionality is necessary and how to use it.\n" + - "You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" + - "`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " + - "to open a file and wait until it is ready; teamId defaults to the current team. " + - "File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\n" + + "You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" + "`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " + "stored attributes can be referenced in future calls to this tool, so any intermediate results that " + "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 8ff05e595b..6dabccbb2f 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,8 +2,7 @@ ### 🚀 Features -- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout. -- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until logout, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 0ef43fdd4d..d1d145fe96 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -786,10 +786,74 @@ export interface WorkspaceContext { readonly teamId: string | null; } -/** Application management API available to global plugins. */ +/** Metadata snapshot of an accessible, non-deleted project. */ +export interface ProjectSummary { + readonly id: string; + readonly teamId: string; + readonly name: string; + /** Whether this is the team's Drafts project. */ + readonly isDefault: boolean; + readonly fileCount: number; +} + +/** Metadata snapshot of an accessible, non-deleted file. */ +export interface FileSummary { + readonly id: string; + readonly teamId: string; + readonly projectId: string; + readonly name: string; + /** Last modification time as an ISO 8601 string in UTC. */ + readonly modifiedAt: string; +} + +/** + * Application management API exposed as `penpotMgmt` to global plugins. + * + * - Requires `content:read` permission. + * - Available in the dashboard and file workspaces. + * - Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * - Reacquire file, page and shape objects after changing files. + */ export interface PenpotMgmt { + /** Current workspace availability and metadata; usable for file operations when status is `ready`. */ readonly workspace: WorkspaceContext; - /** Opens a file in this tab and resolves once its workspace and page are ready. */ + /** + * Lists accessible, non-deleted projects as metadata snapshots. + * + * - `teamId` defaults to the current team. + * - Queries the backend without opening a file or changing navigation. + * - Rejects invalid identifiers and backend access errors. + * + * @example + * ```ts + * const projects = await penpotMgmt.listProjects(); + * ``` + */ + listProjects(options?: { teamId?: string }): Promise; + /** + * Lists accessible, non-deleted files in the specified project as metadata snapshots. + * + * - Requires an explicit `projectId`. + * - Queries the backend without opening a file or changing navigation. + * - Rejects invalid identifiers and backend access errors. + * + * @example + * ```ts + * const files = await penpotMgmt.listFiles({ projectId: project.id }); + * ``` + */ + listFiles(options: { projectId: string }): Promise; + /** + * Opens a file in this tab and resolves once its workspace and page are ready. + * + * - `teamId` defaults to the current team. + * - Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds. + * + * @example + * ```ts + * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + * ``` + */ openFile(fileId: string, options?: { teamId?: string }): Promise; /** Subscribes to workspace availability and metadata changes. */ on( @@ -803,7 +867,10 @@ export interface PenpotMgmt { * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { - readonly management?: Pick; + readonly management?: Pick< + PenpotMgmt, + 'workspace' | 'openFile' | 'listProjects' | 'listFiles' + >; /** * Returns the current penpot version. */ diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index a0ef062d3e..ba36b1e12a 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -419,6 +419,14 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.workspace; }, + listProjects(options) { + checkPermission('content:read', false); + return plugin.context.management!.listProjects(options); + }, + listFiles(options) { + checkPermission('content:read', false); + return plugin.context.management!.listFiles(options); + }, openFile(fileId, options) { checkPermission('content:read', false); return plugin.context.management!.openFile(fileId, options); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 308fb81245..3513b97acd 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -54,6 +54,60 @@ describe('Plugin api', () => { vi.clearAllMocks(); }); + describe('management listing', () => { + it.each(['none', 'loading', 'ready'])( + 'lists metadata with workspace status %s', + async (status) => { + const projects = [{ id: 'project-id' }]; + const files = [{ id: 'file-id' }]; + const management = { + workspace: { status }, + listProjects: vi.fn().mockResolvedValue(projects), + listFiles: vi.fn().mockResolvedValue(files), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, scope: 'global' }, + context: { ...pluginManager.context, management }, + } as any); + + await expect(penpotMgmt.listProjects()).resolves.toEqual(projects); + expect(management.listProjects).toHaveBeenLastCalledWith(undefined); + await penpotMgmt.listProjects({ teamId: 'team-id' }); + expect(management.listProjects).toHaveBeenLastCalledWith({ + teamId: 'team-id', + }); + await expect( + penpotMgmt.listFiles({ projectId: 'project-id' }), + ).resolves.toEqual(files); + expect(management.listFiles).toHaveBeenCalledWith({ + projectId: 'project-id', + }); + }, + ); + + it('requires content:read before querying metadata', () => { + const management = { + listProjects: vi.fn(), + listFiles: vi.fn(), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: [], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); + + expect(() => penpotMgmt.listProjects()).toThrow(); + expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow(); + expect(management.listProjects).not.toHaveBeenCalled(); + expect(management.listFiles).not.toHaveBeenCalled(); + }); + }); + describe('ui', () => { describe.concurrent('permissions', () => { const api = createApi({ From 036f59266bca54831fb565f98024bb7f97889490 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 17:58:52 +0000 Subject: [PATCH 058/325] :bug: Discard cancelled plugin loads after unloading Cause: - Disabling MCP during script loading could leave a late plugin instance attached to a stopped controller, blocking connection after re-enabling. Fix: - Track pending loads and discard instances cancelled by unload. - Prevent duplicate global loads and preserve newer replacement instances. - Clear pending state after failures so a subsequent load can retry. Validation: - Pass 84 runtime tests, the runtime build, lint and formatting checks. - Reproduce the race with a delayed script in the browser, then verify re-enabling restores the session ID, themed indicator and MCP execution. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- plugins/CHANGELOG.md | 2 +- .../src/lib/load-plugin-real-path.spec.ts | 73 ++++++++++++++++++- .../plugins-runtime/src/lib/load-plugin.ts | 16 +++- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 6dabccbb2f..94cd3f268c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🚀 Features -- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until logout, with `penpotMgmt` for project and file discovery, file navigation and workspace events. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts index 4151e09fea..2200f434b1 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts @@ -1,6 +1,11 @@ import { describe, it, vi, expect, beforeAll } from 'vitest'; import 'ses'; -import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin'; +import { + loadPlugin, + setContextBuilder, + getPlugins, + ɵunloadPlugin, +} from './load-plugin'; import type { Context } from '@penpot/plugin-types'; import type { Manifest } from './models/manifest.model.js'; @@ -69,7 +74,7 @@ function makeHostFixture() { // public penpot API. Plugin code must never see it (see B-2 below). __internalSecret: 'host-internal', } as unknown as Context; - return { context, listenerTypes, createRectangle, selection }; + return { context, listeners, listenerTypes, createRectangle, selection }; } function lastCompartmentGlobalThis(): Record { @@ -171,6 +176,70 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(fixture.createRectangle).toHaveBeenCalledTimes(1); expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true); }); + it.each(['before', 'after'])( + 'discards a cancelled global load that completes %s its replacement', + async (order) => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', []), + host: 'https://plugins.test/', + scope: 'global' as const, + }; + ɵunloadPlugin(manifest.pluginId); + let finishFetch!: (response: object) => void; + const response = new Promise((resolve) => { + finishFetch = resolve; + }); + const fetch = vi.fn().mockReturnValue(response); + vi.stubGlobal('fetch', fetch); + try { + const cancelled = loadPlugin(manifest); + await loadPlugin(manifest); + expect(fetch).toHaveBeenCalledOnce(); + ɵunloadPlugin(manifest.pluginId); + const replacement = () => + loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "replacement";', + }); + if (order === 'after') await replacement(); + finishFetch({ + ok: true, + text: async () => 'globalThis.marker = "cancelled";', + }); + await cancelled; + if (order === 'before') { + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + await replacement(); + } + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('replacement'); + expect(fixture.listeners.size).toBe(3); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }, + ); + + it('allows retrying a global plugin after a failed load', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('throw new Error("load failed");', []), + scope: 'global' as const, + }; + await expect(loadPlugin(manifest)).rejects.toThrow('load failed'); + expect(getPlugins()).toHaveLength(0); + await loadPlugin({ ...manifest, code: 'globalThis.marker = "retry";' }); + expect(lastCompartmentGlobalThis()['marker']).toBe('retry'); + ɵunloadPlugin(manifest.pluginId); + expect(fixture.listeners.size).toBe(0); + }); + it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => { const fixture = makeHostFixture(); let workspace = { diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index be332d9058..bad0d4fd63 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -5,6 +5,7 @@ import { Manifest } from './models/manifest.model.js'; import { createPlugin } from './create-plugin.js'; let plugins: Awaited>[] = []; +const pendingPlugins = new Map(); export type ContextBuilder = (id: string) => Context; @@ -49,6 +50,7 @@ export const loadPlugin = async function ( closeCallback?: () => void, apiExtensions?: object, ) { + const loadId = Symbol(); try { const context = contextBuilder && contextBuilder(manifest.pluginId); @@ -58,6 +60,7 @@ export const loadPlugin = async function ( if (manifest.scope === 'global') { if ( + pendingPlugins.has(manifest.pluginId) || plugins.some( (plugin) => plugin.manifest?.pluginId === manifest.pluginId, ) @@ -83,7 +86,9 @@ export const loadPlugin = async function ( // `createSandbox`'s proxy handler applies `ses.safeReturn` to values // crossing into the sandbox. Compartment isolation and intrinsics // hardening are performed by createSandbox, not here. - let plugin: Awaited> | undefined = undefined; + pendingPlugins.set(manifest.pluginId, loadId); + let plugin: Awaited> | undefined = + undefined; plugin = await createPlugin( context, manifest, @@ -96,10 +101,18 @@ export const loadPlugin = async function ( }, apiExtensions, ); + if (pendingPlugins.get(manifest.pluginId) !== loadId) { + plugin.plugin.close(); + return; + } plugins.push(plugin); } catch (error) { if (manifest.scope !== 'global') closeAllPlugins(); throw error; + } finally { + if (pendingPlugins.get(manifest.pluginId) === loadId) { + pendingPlugins.delete(manifest.pluginId); + } } }; @@ -117,6 +130,7 @@ export const ɵloadPluginByUrl = async function (manifestUrl: string) { }; export const ɵunloadPlugin = function (id: Manifest['pluginId']) { + pendingPlugins.delete(id); const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id); if (plugin) { From c4d2ca9cd10fba6635f6d51e66b95b22539ab318 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:23:29 +0200 Subject: [PATCH 059/325] :sparkles: Move the dashboard MCP menu to the page headers Show the MCP menu next to the layout toggle in the header of the projects, files and deleted pages, instead of in the sidebar. It uses the secondary button style there and a visible dot colour while disconnected. Clicking the MCP button now toggles the menu instead of only opening it. Dashboard events report "dashboard:header" as their origin. --- docs/mcp/index.md | 2 +- .../src/app/main/ui/components/mcp_menu.cljs | 27 +++++++++++++------ .../src/app/main/ui/components/mcp_menu.scss | 10 +++---- .../src/app/main/ui/dashboard/deleted.cljs | 2 ++ frontend/src/app/main/ui/dashboard/files.cljs | 2 ++ .../src/app/main/ui/dashboard/projects.cljs | 2 ++ .../src/app/main/ui/dashboard/sidebar.cljs | 11 -------- 7 files changed, 31 insertions(+), 25 deletions(-) diff --git a/docs/mcp/index.md b/docs/mcp/index.md index cce812b516..fb9c927106 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -81,7 +81,7 @@ Some important concepts for users: Connect the tabs you want to use: -* **Remote MCP**: choose **MCP → Connect** in the dashboard sidebar or a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. +* **Remote MCP**: choose **MCP → Connect** in the dashboard header or a file's toolbar. Use **Disconnect** in the same place to disconnect that tab. * **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. * Connecting or disconnecting one tab does not disconnect the others. * A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file. diff --git a/frontend/src/app/main/ui/components/mcp_menu.cljs b/frontend/src/app/main/ui/components/mcp_menu.cljs index f38e4f377b..8e5091bfd3 100644 --- a/frontend/src/app/main/ui/components/mcp_menu.cljs +++ b/frontend/src/app/main/ui/components/mcp_menu.cljs @@ -7,9 +7,11 @@ (ns app.main.ui.components.mcp-menu (:require-macros [app.main.style :as stl]) (:require + [app.config :as cf] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] [app.main.data.workspace.mcp :as mcp] + [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.ds.buttons.button :refer [button*]] @@ -36,13 +38,13 @@ open-timer* (mf/use-ref nil) close-timer* (mf/use-ref nil) - on-open-menu + on-toggle-menu (mf/use-fn (fn [event] (dom/stop-propagation event) (cancel-timer! open-timer*) (cancel-timer! close-timer*) - (reset! menu-open* true))) + (swap! menu-open* not))) on-close-menu (mf/use-fn @@ -80,7 +82,7 @@ (mf/deps dashboard) #(st/emit! (mcp/connect-mcp) (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin (if dashboard "dashboard:sidebar" "workspace:toolbar")}))) + ::ev/origin (if dashboard "dashboard:header" "workspace:toolbar")}))) on-disconnect (mf/use-fn @@ -102,8 +104,8 @@ [:div {:class (stl/css-case :mcp-tool true :dashboard dashboard) :on-pointer-enter on-display-menu :on-pointer-leave on-hide-menu} - [:> button* {:variant "ghost" - :on-click on-open-menu + [:> button* {:variant (if dashboard "secondary" "ghost") + :on-click on-toggle-menu :aria-haspopup true :aria-expanded menu-open? :aria-pressed menu-open? @@ -115,9 +117,7 @@ :connected is-mcp-connected)}] [:span {:class (stl/css-case :toolbar-mcp-button-label true :connected is-mcp-connected)} - (if (and dashboard is-mcp-connected) - (tr "workspace.toolbar.mcp-connected") - (tr "workspace.toolbar.mcp"))]]] + (tr "workspace.toolbar.mcp")]]] [:div {:class (stl/css :toolbar-mcp-menu)} [:> dropdown-menu* {:show menu-open? @@ -139,3 +139,14 @@ (if is-connection-requested (tr "workspace.header.menu.mcp.plugin.status.disconnect") (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) + +(mf/defc dashboard-mcp-menu* + [] + (let [mcp-state (mf/deref refs/mcp)] + (when (and (contains? cf/flags :mcp) + (:enabled mcp-state) + (:token-valid mcp-state)) + [:> mcp-menu* {:dashboard true + :is-mcp-connected (= "connected" (:connection-status mcp-state)) + :is-connection-requested (:connection-requested mcp-state) + :session-id (:session-id mcp-state)}]))) diff --git a/frontend/src/app/main/ui/components/mcp_menu.scss b/frontend/src/app/main/ui/components/mcp_menu.scss index f4c69ba4c8..fd466d4206 100644 --- a/frontend/src/app/main/ui/components/mcp_menu.scss +++ b/frontend/src/app/main/ui/components/mcp_menu.scss @@ -13,10 +13,10 @@ .mcp-tool { position: relative; -} -.dashboard { - margin-inline: var(--sp-l); + &.dashboard { + --mcp-disconnected-dot-color: var(--color-foreground-secondary); + } } .toolbar-mcp-button { @@ -38,7 +38,7 @@ inline-size: px2rem(6); block-size: px2rem(6); border-radius: 50%; - background-color: var(--color-background-disabled); + background-color: var(--mcp-disconnected-dot-color, var(--color-background-disabled)); &.connected { background-color: var(--color-accent-primary); @@ -72,6 +72,7 @@ .toolbar-mcp-menu { position: absolute; + z-index: var(--z-index-dropdown); left: 0; top: $sz-36; } @@ -85,7 +86,6 @@ display: flex; flex-direction: column; gap: var(--sp-xs); - z-index: var(--z-index-dropdown); margin: 0; padding: var(--sp-xs); border: $b-2 solid var(--panel-border-color); diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 2f41c028e6..6bef519d98 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -16,6 +16,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] + [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.grid :refer [grid*]] [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] @@ -64,6 +65,7 @@ [:div#dashboard-deleted-title {:class (stl/css :dashboard-title)} [:h1 (tr "dashboard.projects-title")]] [:div {:class (stl/css :dashboard-header-actions)} + [:> dashboard-mcp-menu*] [:> layout-toggle* {:layout layout :on-change on-change}]]]) (mf/defc project-context-menu* diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index b3318fc6f8..2d6a98f519 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -14,6 +14,7 @@ [app.main.data.project :as dpj] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.grid :refer [grid*]] [app.main.ui.dashboard.import :as udi] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -102,6 +103,7 @@ (:name project)]])) [:div {:class (stl/css :dashboard-header-actions)} + [:> dashboard-mcp-menu*] [:> layout-toggle* {:layout layout :on-change on-change}] diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index fef26f5b9b..2efbbefd21 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -17,6 +17,7 @@ [app.main.data.team :as dtm] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.deleted :as deleted] [app.main.ui.dashboard.grid :refer [line-grid*]] [app.main.ui.dashboard.import :as udi] @@ -50,6 +51,7 @@ (tr "dashboard.personal-projects") (tr "dashboard.projects-title"))]] [:div {:class (stl/css :dashboard-header-actions)} + [:> dashboard-mcp-menu*] [:> layout-toggle* {:layout layout :on-change on-change}] (when can-edit diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 8787c290ca..2f37187244 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -23,7 +23,6 @@ [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] - [app.main.ui.components.mcp-menu :refer [mcp-menu*]] [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] @@ -256,7 +255,6 @@ (get default-project :id) team-id (get team :id) - mcp-state (mf/deref refs/mcp) projects? (= section :dashboard-recent) fonts? (= section :dashboard-fonts) @@ -384,15 +382,6 @@ [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] - (when (and (contains? cf/flags :mcp) - (:enabled mcp-state) - (:token-valid mcp-state)) - [:div {:class (stl/css :sidebar-content-section)} - [:> mcp-menu* {:dashboard true - :is-mcp-connected (= "connected" (:connection-status mcp-state)) - :is-connection-requested (:connection-requested mcp-state) - :session-id (:session-id mcp-state)}]]) - [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} [:div {:class (stl/css :sidebar-section-title)} From cf1fbb4d7cf0f2ea3620516da5ba3f9378bcbc7f Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:23:47 +0200 Subject: [PATCH 060/325] :bug: Close plugins whose load is cancelled before they run Unloading or replacing a plugin while its code was still being fetched could leave a late instance running, or block reopening a plugin that closed during startup. Each load now carries an AbortController. Unloading, a newer load of the same plugin or logout aborts it, and the plugin manager closes itself on abort, also while it fetches its code. createPlugin returns nothing for a plugin closed before its sandbox exists, and failed loads clear their pending state so the plugin can be loaded again. --- plugins/CHANGELOG.md | 1 + .../src/lib/create-plugin.spec.ts | 1 + .../plugins-runtime/src/lib/create-plugin.ts | 12 +- .../src/lib/load-plugin-context.spec.ts | 3 +- .../src/lib/load-plugin-real-path.spec.ts | 189 +++++++++++++++++- .../src/lib/load-plugin.spec.ts | 13 +- .../plugins-runtime/src/lib/load-plugin.ts | 27 ++- .../plugins-runtime/src/lib/plugin-manager.ts | 21 +- 8 files changed, 241 insertions(+), 26 deletions(-) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 94cd3f268c..433f43e8c2 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🩹 Fixes +- **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup. - **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction. - **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape. - **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported. diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts index 29d0b6d691..7960f77ec2 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts @@ -83,6 +83,7 @@ describe('createPlugin', () => { manifest, expect.any(Function), expect.any(Function), + undefined, ); expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined); expect(mockSandbox.evaluate).toHaveBeenCalled(); diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts index fc4ca233f3..c2b4a2f548 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts @@ -8,8 +8,13 @@ export async function createPlugin( manifest: Manifest, onCloseCallback: () => void, apiExtensions?: object, + signal?: AbortSignal, ) { + // Closing can happen while the manager is still fetching code. + let sandbox: ReturnType | undefined = undefined; + const evaluateSandbox = async () => { + if (plugin.destroyed || !sandbox) return; try { sandbox.evaluate(); } catch (error) { @@ -23,15 +28,18 @@ export async function createPlugin( context, manifest, function onClose() { - sandbox.cleanGlobalThis(); + sandbox?.cleanGlobalThis(); onCloseCallback(); }, function onReloadModal() { evaluateSandbox(); }, + signal, ); - const sandbox = createSandbox(plugin, apiExtensions); + if (plugin.destroyed) return; + + sandbox = createSandbox(plugin, apiExtensions); await evaluateSandbox(); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts index 52a0267db0..9305878b3f 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts @@ -30,7 +30,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => { close: vi.fn(), sendMessage: vi.fn(), }, - } as unknown as Awaited>); + } as unknown as NonNullable>>); }); it('does not freeze host-owned functions reachable through the context', async () => { @@ -60,6 +60,7 @@ describe('loadPlugin host context boundary (regression for #11001)', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); // Host-owned functions must remain extensible: page navigation and diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts index 2200f434b1..f921455fbe 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts @@ -52,15 +52,23 @@ function makeManifest( function makeHostFixture() { const listenerTypes: string[] = []; - const listeners = new Map(); + const listeners = new Map< + symbol, + { type: string; callback: (...args: unknown[]) => unknown } + >(); + const shapes: object[] = []; // Inline code (empty host + non-URL code) resolves without network, so no // fetch mock is needed. UI/modal APIs are never touched by the probe code. - const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' })); + const createRectangle = vi.fn(() => { + const shape = { type: 'rectangle-marker' }; + shapes.push(shape); + return shape; + }); const selection: object[] = [{ id: 'shape-1' }]; const context = { - addListener: (type: string, _callback: (...args: unknown[]) => unknown) => { + addListener: (type: string, callback: (...args: unknown[]) => unknown) => { const id = Symbol(type); - listeners.set(id, type); + listeners.set(id, { type, callback }); listenerTypes.push(type); return id; }, @@ -68,13 +76,21 @@ function makeHostFixture() { listeners.delete(id); }, theme: 'dark', + management: { workspace: { status: 'ready' } }, createRectangle, selection, // Host-only member: present on the raw context but NOT part of the // public penpot API. Plugin code must never see it (see B-2 below). __internalSecret: 'host-internal', } as unknown as Context; - return { context, listeners, listenerTypes, createRectangle, selection }; + return { + context, + listeners, + listenerTypes, + createRectangle, + selection, + shapes, + }; } function lastCompartmentGlobalThis(): Record { @@ -182,7 +198,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { const fixture = makeHostFixture(); setContextBuilder(() => fixture.context); const manifest = { - ...makeManifest('plugin.js', []), + ...makeManifest('plugin.js', ['content:read', 'content:write']), host: 'https://plugins.test/', scope: 'global' as const, }; @@ -207,9 +223,10 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { if (order === 'after') await replacement(); finishFetch({ ok: true, - text: async () => 'globalThis.marker = "cancelled";', + text: async () => 'penpot.createRectangle();', }); await cancelled; + expect(fixture.shapes).toEqual([]); if (order === 'before') { expect(getPlugins()).toHaveLength(0); expect(fixture.listeners.size).toBe(0); @@ -225,6 +242,74 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { }, ); + it('cancels a workspace load when another load replaces the same plugin', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', ['content:read', 'content:write']), + host: 'https://plugins.test/', + }; + let finishFetch!: (response: object) => void; + vi.stubGlobal( + 'fetch', + () => + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + try { + const cancelled = loadPlugin(manifest); + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "replacement";', + }); + finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' }); + await cancelled; + expect(fixture.shapes).toEqual([]); + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('replacement'); + } finally { + for (const plugin of [...getPlugins()]) plugin.plugin.close(); + vi.unstubAllGlobals(); + } + }); + + it('ignores a fetch failure after a load has been cancelled', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', []), + host: 'https://plugins.test/', + scope: 'global' as const, + }; + let failFetch!: (error: Error) => void; + vi.stubGlobal( + 'fetch', + () => + new Promise((_resolve, reject) => { + failFetch = reject; + }), + ); + try { + const cancelled = loadPlugin(manifest); + ɵunloadPlugin(manifest.pluginId); + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "replacement";', + }); + failFetch(new Error('Cancelled fetch failed')); + await cancelled; + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('replacement'); + expect(fixture.listeners.size).toBe(3); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }); + it('allows retrying a global plugin after a failed load', async () => { const fixture = makeHostFixture(); setContextBuilder(() => fixture.context); @@ -315,4 +400,94 @@ describe('loadPlugin real initialization path (regression for #11001)', () => { expect(globals['penpotMgmt']).toBeUndefined(); expect(listeners.size).toBe(0); }); + + it('cancels a global load at logout before it can execute in another session', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', ['content:read', 'content:write']), + pluginId: 'logout-plugin', + host: 'https://plugins.test/', + scope: 'global' as const, + }; + let finishFetch!: (response: object) => void; + vi.stubGlobal( + 'fetch', + () => + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + try { + const cancelled = loadPlugin(manifest); + for (const listener of [...fixture.listeners.values()]) { + if (listener.type === 'logout') listener.callback(); + } + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "new-session";', + }); + finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' }); + await cancelled; + expect(fixture.shapes).toEqual([]); + expect(getPlugins()).toHaveLength(1); + expect(lastCompartmentGlobalThis()['marker']).toBe('new-session'); + expect(fixture.listeners.size).toBe(3); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }); + + it('cleans up a failed fetch and allows loading the plugin again', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('plugin.js', []), + pluginId: 'failed-fetch-plugin', + host: 'https://plugins.test/', + scope: 'global' as const, + }; + vi.stubGlobal('fetch', () => Promise.reject(new Error('Fetch failed'))); + try { + await expect(loadPlugin(manifest)).rejects.toThrow('Fetch failed'); + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + await loadPlugin({ + ...manifest, + host: '', + code: 'globalThis.marker = "retry";', + }); + expect(lastCompartmentGlobalThis()['marker']).toBe('retry'); + } finally { + ɵunloadPlugin(manifest.pluginId); + vi.unstubAllGlobals(); + } + }); + + it('does not register a global plugin that closes during startup and permits reopening', async () => { + const fixture = makeHostFixture(); + setContextBuilder(() => fixture.context); + const manifest = { + ...makeManifest('penpot.closePlugin();', [ + 'content:read', + 'content:write', + ]), + pluginId: 'self-closing-plugin', + scope: 'global' as const, + }; + try { + await loadPlugin(manifest); + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + await loadPlugin({ ...manifest, code: 'penpot.createRectangle();' }); + expect(fixture.shapes).toEqual([{ type: 'rectangle-marker' }]); + expect(getPlugins()).toHaveLength(1); + } finally { + ɵunloadPlugin(manifest.pluginId); + } + expect(getPlugins()).toHaveLength(0); + expect(fixture.listeners.size).toBe(0); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index 779619279c..eeffb3d236 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -33,7 +33,7 @@ vi.mock('./ses.js', () => ({ describe('plugin-loader', () => { let mockContext: Context; let manifest: Manifest; - let mockPluginApi: Awaited>; + let mockPluginApi: NonNullable>>; let mockClose: ReturnType; beforeEach(() => { @@ -61,7 +61,7 @@ describe('plugin-loader', () => { close: mockClose, sendMessage: vi.fn(), }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; mockContext = { addListener: vi.fn(), @@ -84,6 +84,7 @@ describe('plugin-loader', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); expect(mockPluginApi.plugin.close).not.toHaveBeenCalled(); expect(getPlugins()).toHaveLength(1); @@ -129,7 +130,7 @@ describe('plugin-loader', () => { }, iframeWindow: mockIframeWindow, manifest: { ...manifest, host: 'http://localhost:4202' }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe); @@ -173,7 +174,7 @@ describe('plugin-loader', () => { }, iframeWindow: mockIframeWindow1, manifest: { ...manifest, host: 'http://localhost:4202' }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; const mockPluginApi2 = { plugin: { @@ -182,7 +183,7 @@ describe('plugin-loader', () => { }, iframeWindow: mockIframeWindow2, manifest: { ...manifest, host: 'http://localhost:4203' }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1); await loadPlugin(manifest); @@ -250,6 +251,7 @@ describe('plugin-loader', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); }); @@ -265,6 +267,7 @@ describe('plugin-loader', () => { manifest, expect.any(Function), undefined, + expect.any(AbortSignal), ); }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index bad0d4fd63..8da32c4d64 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -4,8 +4,10 @@ import { loadManifest } from './parse-manifest.js'; import { Manifest } from './models/manifest.model.js'; import { createPlugin } from './create-plugin.js'; -let plugins: Awaited>[] = []; -const pendingPlugins = new Map(); +type Plugin = NonNullable>>; + +let plugins: Plugin[] = []; +const pendingPlugins = new Map(); export type ContextBuilder = (id: string) => Context; @@ -50,7 +52,7 @@ export const loadPlugin = async function ( closeCallback?: () => void, apiExtensions?: object, ) { - const loadId = Symbol(); + const load = new AbortController(); try { const context = contextBuilder && contextBuilder(manifest.pluginId); @@ -86,13 +88,17 @@ export const loadPlugin = async function ( // `createSandbox`'s proxy handler applies `ses.safeReturn` to values // crossing into the sandbox. Compartment isolation and intrinsics // hardening are performed by createSandbox, not here. - pendingPlugins.set(manifest.pluginId, loadId); - let plugin: Awaited> | undefined = - undefined; + pendingPlugins.get(manifest.pluginId)?.abort(); + pendingPlugins.set(manifest.pluginId, load); + let plugin: Plugin | undefined = undefined; plugin = await createPlugin( context, manifest, () => { + load.abort(); + if (pendingPlugins.get(manifest.pluginId) === load) { + pendingPlugins.delete(manifest.pluginId); + } plugins = plugins.filter((api) => api !== plugin); if (closeCallback) { @@ -100,9 +106,10 @@ export const loadPlugin = async function ( } }, apiExtensions, + load.signal, ); - if (pendingPlugins.get(manifest.pluginId) !== loadId) { - plugin.plugin.close(); + if (!plugin || load.signal.aborted) { + plugin?.plugin.close(); return; } plugins.push(plugin); @@ -110,7 +117,7 @@ export const loadPlugin = async function ( if (manifest.scope !== 'global') closeAllPlugins(); throw error; } finally { - if (pendingPlugins.get(manifest.pluginId) === loadId) { + if (pendingPlugins.get(manifest.pluginId) === load) { pendingPlugins.delete(manifest.pluginId); } } @@ -130,7 +137,7 @@ export const ɵloadPluginByUrl = async function (manifestUrl: string) { }; export const ɵunloadPlugin = function (id: Manifest['pluginId']) { - pendingPlugins.delete(id); + pendingPlugins.get(id)?.abort(); const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id); if (plugin) { diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index f430321210..3cb6a9c261 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -14,8 +14,9 @@ export async function createPluginManager( manifest: Manifest, onCloseCallback: () => void, onReloadModal: (code: string) => void, + signal?: AbortSignal, ) { - let code = await loadManifestCode(manifest); + let code = ''; let loaded = false; let destroyed = false; @@ -61,6 +62,7 @@ export async function createPluginManager( const closePlugin = () => { if (destroyed) return; destroyed = true; + signal?.removeEventListener('abort', closePlugin); removeAllEventListeners(); destroyListener(listenerId); destroyListener(logoutId); @@ -148,7 +150,24 @@ export async function createPluginManager( context.removeListener(listenerId); }; + signal?.addEventListener('abort', closePlugin, { once: true }); + try { + if (signal?.aborted) { + closePlugin(); + } else { + code = await loadManifestCode(manifest); + } + } catch (error) { + if (!destroyed) { + closePlugin(); + throw error; + } + } + return { + get destroyed() { + return destroyed; + }, close: closePlugin, destroyListener, openModal, From a381761aa4e2fd82f8aa75a0a9b475126e6125e2 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:24:05 +0200 Subject: [PATCH 061/325] :sparkles: Add project and file creation to penpotMgmt Add penpotMgmt.createProject({name, teamId?}) and penpotMgmt.createFile({projectId, name}). Both require content:write, trim the name and require 1 to 250 characters, and return the same metadata as listProjects and listFiles without changing the current view. When the new project or file belongs to the current team, the dashboard state is updated so it shows up right away. listProjects, listFiles and the creation methods share the summary builders, so fileCount is now 0 instead of missing for new projects. Update the plugin types, the MCP API docs and the MCP instructions. --- frontend/src/app/plugins/management.cljs | 104 ++++++++++++--- .../plugins/management_test.cljs | 126 ++++++++++++++++++ mcp/packages/server/data/api_types.yml | 59 +++++++- .../server/data/initial_instructions.md | 6 +- plugins/CHANGELOG.md | 1 + plugins/libs/plugin-types/index.d.ts | 46 ++++++- .../libs/plugins-runtime/src/lib/api/index.ts | 8 ++ .../src/lib/api/plugin-api.spec.ts | 88 ++++++++++++ 8 files changed, 412 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 9dadac543b..0337da56ba 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -6,13 +6,34 @@ (ns app.plugins.management (:require + [app.common.features :as cfeat] [app.common.uuid :as uuid] + [app.main.data.dashboard :as dd] [app.main.data.helpers :as dsh] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] [app.util.object :as obj] - [beicon.v2.core :as rx])) + [beicon.v2.core :as rx] + [clojure.set :as set] + [cuerdas.core :as str] + [potok.v2.core :as ptk])) + +(defn- project-summary + [project] + #js {:id (str (:id project)) + :teamId (str (:team-id project)) + :name (:name project) + :isDefault (boolean (:is-default project)) + :fileCount (or (:count project) 0)}) + +(defn- file-summary + [file] + #js {:id (str (:id file)) + :teamId (str (:team-id file)) + :projectId (str (:project-id file)) + :name (:name file) + :modifiedAt (.toISOString ^js (:modified-at file))}) (defn workspace-context [state] @@ -83,14 +104,7 @@ (fn [resolve reject] (->> (rp/cmd! :get-projects {:team-id team-id}) (rx/map (fn [projects] - (clj->js - (mapv (fn [project] - {:id (str (:id project)) - :teamId (str (:team-id project)) - :name (:name project) - :isDefault (boolean (:is-default project)) - :fileCount (:count project)}) - (remove :deleted-at projects))))) + (into-array (map project-summary (remove :deleted-at projects))))) (rx/subs! resolve reject))))))) (defn list-files @@ -102,16 +116,68 @@ (fn [resolve reject] (->> (rp/cmd! :get-project-files {:project-id project-id}) (rx/map (fn [files] - (clj->js - (mapv (fn [file] - {:id (str (:id file)) - :teamId (str (:team-id file)) - :projectId (str (:project-id file)) - :name (:name file) - :modifiedAt (.toISOString ^js (:modified-at file))}) - files)))) + (into-array (map file-summary files)))) (rx/subs! resolve reject))))))) +(defn- creation-name + [options] + (let [name (obj/get options "name")] + (when (string? name) + (let [name (str/trim name)] + (when (<= 1 (count name) 250) + name))))) + +(defn create-project + [options] + (let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state)) + name (creation-name options)] + (cond + (nil? team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")) + + (nil? name) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + + :else + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :create-project {:team-id team-id :name name}) + (rx/tap (fn [project] + (st/emit! + (fn [state] + (if (= (:team-id project) (:current-team-id state)) + (assoc-in state [:projects (:id project)] (assoc project :count 0)) + state))))) + (rx/map project-summary) + (rx/subs! resolve reject))))))) + +(defn create-file + [options] + (let [project-id (uuid/parse* (obj/get options "projectId")) + name (creation-name options)] + (cond + (nil? project-id) + (js/Promise.reject (js/Error. "Expected a project UUID")) + + (nil? name) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + + :else + (js/Promise. + (fn [resolve reject] + (let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] + (->> (rp/cmd! :create-file {:project-id project-id :name name :features features}) + (rx/tap (fn [file] + (st/emit! + (fn [state] + (if (= (:team-id file) (:current-team-id state)) + (ptk/update (dd/file-created file) state) + state))))) + (rx/map file-summary) + (rx/subs! resolve reject)))))))) + (defn create-context [] (obj/reify {:name "PenpotManagementContext"} @@ -119,4 +185,6 @@ {:get (fn [] (clj->js (workspace-context @st/state)))} :openFile open-file :listProjects list-projects - :listFiles list-files)) + :listFiles list-files + :createProject create-project + :createFile create-file)) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 9db0ae80c0..eb74e0b09d 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -152,3 +152,129 @@ (mock/with-mocks* {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) + +(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team + (let [project-id (uuid/next) + other-team-id (uuid/next)] + (doseq [[initial options target-team-id] + [[dashboard #js {:name " Buttons "} team-id] + [ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]] + (let [state (atom initial) + requests (atom []) + project {:id project-id :team-id target-team-id :name "Buttons" + :is-default false :is-pinned false}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub + (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of project) (rx/observe-on :async))))} + (let [context (management/create-context) + result (await (.createProject context options))] + (t/is (= {"id" (str project-id) "teamId" (str target-team-id) + "name" "Buttons" "isDefault" false "fileCount" 0} + (js->clj result))) + (t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests)) + (t/is (= (if (= team-id target-team-id) + (assoc-in initial [:projects project-id] (assoc project :count 0)) + initial) + @state))))))))) + +(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation + (let [project-id (uuid/next) + created-id (uuid/next) + modified-at (js/Date. "2026-01-02T03:04:05Z")] + (doseq [initial [dashboard ready]] + (let [initial (assoc initial + :features #{"components/v2" "styles/v2" "plugins/runtime"} + :projects {project-id {:id project-id :team-id team-id :count 2}}) + state (atom initial) + requests (atom []) + file {:id created-id :team-id team-id :project-id project-id + :name "Login" :modified-at modified-at :data {:pages []}}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub + (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of file) (rx/observe-on :async))))} + (let [context (management/create-context) + result (await (.createFile context #js {:projectId (str project-id) :name " Login " + :id "ignored" :isShared true}))] + (t/is (= {"id" (str created-id) "teamId" (str team-id) + "projectId" (str project-id) "name" "Login" + "modifiedAt" "2026-01-02T03:04:05.000Z"} + (js->clj result))) + (t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]] + @requests)) + (t/is (= (-> initial + (assoc-in [:files created-id] (dissoc file :data)) + (assoc-in [:recent-files created-id] (dissoc file :data)) + (update-in [:projects project-id :count] inc)) + @state))))))))) + +(t/deftest ^:async creation-rejects-invalid-input-before-querying + (let [project-id (uuid/next) + requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (mock/stub (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of nil) (rx/observe-on :async))))} + (let [context (management/create-context)] + (doseq [options [nil #js {} #js {:name nil} #js {:name 1} + #js {:name ""} #js {:name " \n "} + #js {:name (apply str (repeat 251 "a"))}]] + (t/is (= "Expected a name with 1 to 250 characters" + (await (.then (.createProject context options) (fn [] nil) #(.-message %))))) + (let [options (js/Object.assign #js {:projectId (str project-id)} options)] + (t/is (= "Expected a name with 1 to 250 characters" + (await (.then (.createFile context options) (fn [] nil) #(.-message %))))))) + (doseq [[create options message] + [[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"] + [#(.createFile context %) #js {:name "Login"} "Expected a project UUID"] + [#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]] + (t/is (= message (await (.then (create options) (fn [] nil) #(.-message %))))))) + (await (a/settle)) + (t/is (empty? @requests)))))) + +(t/deftest ^:async creation-preserves-backend-errors-and-state + (doseq [method ["createProject" "createFile"]] + (let [state (atom dashboard) + error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))} + (let [context (management/create-context) + options #js {:name "Design" :projectId (str (uuid/next))} + result (.call (aget context method) context options)] + (t/is (identical? error (await (.then result (fn [] nil) identity)))) + (t/is (= dashboard @state)))))))) + +(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation + (doseq [method ["createProject" "createFile"]] + (let [state (atom dashboard) + response (rx/subject) + other (assoc dashboard :current-team-id (uuid/next)) + item-id (uuid/next) + item {:id item-id :team-id team-id :project-id (uuid/next) + :name "Design" :modified-at (js/Date.)}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))} + (let [context (management/create-context) + result (.call (aget context method) context + #js {:name "Design" :projectId (str (:project-id item))})] + (reset! state other) + (rx/push! response item) + (t/is (= (str item-id) (.-id (await result)))) + (t/is (= other @state)))))))) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index c926147938..1fbccd549f 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5128,9 +5128,9 @@ PenpotMgmt: Application management API exposed as `penpotMgmt` to global plugins. - * Requires `content:read` permission. + * Discovery and navigation require `content:read`; creation requires `content:write`. * Available in the dashboard and file workspaces. - * Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. * Reacquire file, page and shape objects after changing files. ``` @@ -5138,6 +5138,8 @@ PenpotMgmt: workspace: WorkspaceContext; listProjects(options?: { teamId?: string }): Promise; listFiles(options: { projectId: string }): Promise; + createProject(options: { name: string; teamId?: string }): Promise; + createFile(options: { name: string; projectId: string }): Promise; openFile(fileId: string, options?: { teamId?: string }): Promise; on( type: "workspacechange", @@ -5199,6 +5201,51 @@ PenpotMgmt: ``` const files = await penpotMgmt.listFiles({ projectId: project.id }); ``` + createProject: |- + ``` + createProject(options: { name: string; teamId?: string }): Promise + ``` + + Creates a project and returns its metadata snapshot. Requires `content:write`. + + * `teamId` defaults to the current team. + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation or open a file. + * Rejects invalid input, backend access errors and quota errors. + + Parameters + + * options: { name: string; teamId?: string } + + Returns Promise + + Example + ``` + const project = await penpotMgmt.createProject({ name: 'Design system' }); + ``` + createFile: |- + ``` + createFile(options: { name: string; projectId: string }): Promise + ``` + + Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. + + * Requires an explicit `projectId`. + * Trims `name` and requires 1 to 250 characters. + * Does not open the file or change navigation. Use `openFile` to edit it. + * Rejects invalid input, backend access errors and quota errors. + + Parameters + + * options: { name: string; projectId: string } + + Returns Promise + + Example + ``` + const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); + await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + ``` openFile: |- ``` openFile(fileId: string, options?: { teamId?: string }): Promise @@ -5260,7 +5307,9 @@ Context: "workspace" | "openFile" | "listProjects" - | "listFiles", + | "listFiles" + | "createProject" + | "createFile", >; version: string; root: Shape | null; @@ -5340,7 +5389,9 @@ Context: "workspace" | "openFile" | "listProjects" - | "listFiles", + | "listFiles" + | "createProject" + | "createFile", > ``` version: |- diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index ea5961c605..6fd27c2e81 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -435,7 +435,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u # The `penpotMgmt` Object This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. -It allows you to discover and navigate to different design files the user has access to. +It allows you to discover, create and navigate to design projects and files the user has access to. +Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`). +Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`. +Both creation methods require `content:write` and return metadata without changing the current view. +To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. -- diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 433f43e8c2..ab324f891c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,7 @@ ### 🚀 Features +- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index d1d145fe96..f375f42cd3 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -809,9 +809,9 @@ export interface FileSummary { /** * Application management API exposed as `penpotMgmt` to global plugins. * - * - Requires `content:read` permission. + * - Discovery and navigation require `content:read`; creation requires `content:write`. * - Available in the dashboard and file workspaces. - * - Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. * - Reacquire file, page and shape objects after changing files. */ export interface PenpotMgmt { @@ -843,6 +843,41 @@ export interface PenpotMgmt { * ``` */ listFiles(options: { projectId: string }): Promise; + /** + * Creates a project and returns its metadata snapshot. Requires `content:write`. + * + * - `teamId` defaults to the current team. + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation or open a file. + * - Rejects invalid input, backend access errors and quota errors. + * + * @example + * ```ts + * const project = await penpotMgmt.createProject({ name: 'Design system' }); + * ``` + */ + createProject(options: { + name: string; + teamId?: string; + }): Promise; + /** + * Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. + * + * - Requires an explicit `projectId`. + * - Trims `name` and requires 1 to 250 characters. + * - Does not open the file or change navigation. Use `openFile` to edit it. + * - Rejects invalid input, backend access errors and quota errors. + * + * @example + * ```ts + * const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); + * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + * ``` + */ + createFile(options: { + name: string; + projectId: string; + }): Promise; /** * Opens a file in this tab and resolves once its workspace and page are ready. * @@ -869,7 +904,12 @@ export interface PenpotMgmt { export interface Context { readonly management?: Pick< PenpotMgmt, - 'workspace' | 'openFile' | 'listProjects' | 'listFiles' + | 'workspace' + | 'openFile' + | 'listProjects' + | 'listFiles' + | 'createProject' + | 'createFile' >; /** * Returns the current penpot version. diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index ba36b1e12a..44f5645cc5 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -427,6 +427,14 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.listFiles(options); }, + createProject(options) { + checkPermission('content:write', false); + return plugin.context.management!.createProject(options); + }, + createFile(options) { + checkPermission('content:write', false); + return plugin.context.management!.createFile(options); + }, openFile(fileId, options) { checkPermission('content:read', false); return plugin.context.management!.openFile(fileId, options); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 3513b97acd..e53eddacc3 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -108,6 +108,94 @@ describe('Plugin api', () => { }); }); + describe('management creation', () => { + it.each(['none', 'loading', 'ready'])( + 'creates projects and files with workspace status %s', + async (status) => { + const project = { id: 'project-id', name: 'Buttons' }; + const file = { id: 'file-id', name: 'Login' }; + const management = { + workspace: { status }, + createProject: vi.fn().mockResolvedValue(project), + createFile: vi.fn().mockResolvedValue(file), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: ['content:write'], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); + + await expect( + penpotMgmt.createProject({ name: 'Buttons' }), + ).resolves.toEqual(project); + await expect( + penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }), + ).resolves.toEqual(project); + expect(management.createProject).toHaveBeenLastCalledWith({ + name: 'Buttons', + teamId: 'team-id', + }); + await expect( + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).resolves.toEqual(file); + expect(management.createFile).toHaveBeenCalledWith({ + name: 'Login', + projectId: 'project-id', + }); + }, + ); + + it.each([{ permissions: [] }, { permissions: ['content:read'] }])( + 'requires content:write before creating with permissions $permissions', + ({ permissions }) => { + const management = { + createProject: vi.fn(), + createFile: vi.fn(), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, permissions, scope: 'global' }, + context: { ...pluginManager.context, management }, + } as any); + + expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow( + 'Permission content:write is not granted', + ); + expect(() => + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).toThrow('Permission content:write is not granted'); + expect(management.createProject).not.toHaveBeenCalled(); + expect(management.createFile).not.toHaveBeenCalled(); + }, + ); + + it('preserves backend creation errors', async () => { + const error = new Error('Access denied'); + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, scope: 'global' }, + context: { + ...pluginManager.context, + management: { + createProject: vi.fn().mockRejectedValue(error), + createFile: vi.fn().mockRejectedValue(error), + }, + }, + } as any); + + await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe( + error, + ); + await expect( + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).rejects.toBe(error); + }); + }); + describe('ui', () => { describe.concurrent('permissions', () => { const api = createApi({ From dfff188409799865d6b05151d43e8c55af813c31 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:24:12 +0200 Subject: [PATCH 062/325] :sparkles: Ask users to allow global plugins on install A plugin with "scope": "global" keeps running across the dashboard and files and can find and open the user's projects and files. Any manifest could ask for this without the user being told. Add the allow:global permission. The plugin registry adds it to every manifest with global scope, so the install dialog lists it, and a plugin that becomes global in a later version shows the permissions update dialog again. The permission is valid in the shared schema, which the backend checks when saving, and in the runtime manifest schema. The built-in MCP plugin declares it too. Document the scope property and the new permission for plugin developers. --- .../test/backend_tests/rpc_plugins_test.clj | 11 ++++++++++ common/src/app/common/types/plugins.cljc | 5 +++-- docs/plugins/getting-started.md | 4 ++++ frontend/src/app/main/data/workspace/mcp.cljs | 3 ++- .../src/app/main/ui/workspace/plugins.cljs | 7 +++++++ frontend/src/app/plugins/register.cljs | 17 +++++++++------ .../frontend_tests/plugins/register_test.cljs | 21 +++++++++++++++++++ frontend/translations/en.po | 6 ++++++ frontend/translations/es.po | 6 ++++++ plugins/CHANGELOG.md | 2 +- .../src/lib/models/manifest.schema.ts | 1 + 11 files changed, 73 insertions(+), 10 deletions(-) diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj index 8c57931844..8d6be84dc6 100644 --- a/backend/test/backend_tests/rpc_plugins_test.clj +++ b/backend/test/backend_tests/rpc_plugins_test.clj @@ -47,6 +47,17 @@ (t/is (= [plugin-id-1] (:ids plugins))) (t/is (= valid-plugin (get-in plugins [:data plugin-id-1])))))) +(t/deftest add-profile-plugin-accepts-global-plugins + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin + :scope "global" + :permissions #{"content:read" "allow:global"}) + out (th/command! {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin})] + (t/is (nil? (:error out))) + (t/is (= plugin (:result out))))) + (t/deftest add-profile-plugin-rejects-invalid-permissions (let [profile (th/create-profile* 1) plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"}) diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index b9a1ca5330..8db72497fd 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -35,11 +35,12 @@ "clipboard:read" "clipboard:write" "user:read" "allow:downloads" - "allow:localstorage"}) + "allow:localstorage" + "allow:global"}) (def schema:permissions "Schema for plugin permissions - a set of valid permission strings." - [:set {:gen/max 11} (into [:enum] (sort valid-permissions))]) + [:set {:gen/max 12} (into [:enum] (sort valid-permissions))]) (def max-plugins "Maximum number of plugins a profile can hold." diff --git a/docs/plugins/getting-started.md b/docs/plugins/getting-started.md index ea993640a9..5f72ac4ada 100644 --- a/docs/plugins/getting-started.md +++ b/docs/plugins/getting-started.md @@ -160,6 +160,7 @@ Set "version": 2 in your - **Code**: your plugin's file location. It needs to be compiled to JavaScript and reachable. - **Icon**: your plugin's icon, which will be also displayed in the plugin manager modal. It'll be a tag so you can use whichever image format works better for you. **It's recommended to use a 56x56 pixel icon for the best appearance on all devices**. - **Permissions**: your plugin's permissions, which allow access to different parts of the Penpot API. +- **Scope** (optional): "workspace" (the default) or "global". A workspace plugin closes when its file closes. A global plugin keeps running while the user moves between the dashboard and files, and gets the penpotMgmt object to find, create and open projects and files. Global scope adds the allow:global permission, which the user must accept. #### Types of permissions @@ -185,6 +186,9 @@ Typical use cases: downloading the full project file for backup or sharing. - allow:localstorage: Allows the access to the local storage proxy to store information. This info is only available for the plugin installation but be aware that a user can see this information in the browser. Typical use cases: storing authentication tokens for a plugin login +- allow:global: Allows the plugin to keep running while the user moves between the dashboard and files, and to find and open their projects and files. Added automatically when the manifest sets "scope": "global". +Typical use cases: assistants or integrations that work across several files. + _Note: Write permissions automatically includes its corresponding read permission (e.g., content:write includes content:read) because reading is required to perform write or modification actions._ ### What are plugin.ts and plugin.js files? diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index f3e3817239..e4b03fb956 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -42,7 +42,8 @@ #{"user:read" "library:read" "library:write" "comment:read" "comment:write" - "content:write" "content:read"}}) + "content:write" "content:read" + "allow:global"}}) (defonce interval-sub (atom nil)) diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index a6ff1b34f0..12bd45e766 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -331,6 +331,13 @@ [:p {:class (stl/css :permissions-list-text)} (tr "workspace.plugins.permissions.allow-localstorage")]]) + (cond + (contains? permissions "allow:global") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.allow-global")]]) + (cond (contains? permissions "clipboard:write") [:div {:class (stl/css :permissions-list-entry)} diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index fc709427d4..8b5bcc3bde 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -54,15 +54,20 @@ "Read the manifest.json defined by the plugins definition and transforms it into an object that will be stored in the register." [plugin-url ^js manifest] - (let [name (obj/get manifest "name") - desc (obj/get manifest "description") - code (obj/get manifest "code") - icon (obj/get manifest "icon") - vers (d/nilv (obj/get manifest "version") 1) + (let [name (obj/get manifest "name") + desc (obj/get manifest "description") + code (obj/get manifest "code") + icon (obj/get manifest "icon") + vers (d/nilv (obj/get manifest "version") 1) + scope (obj/get manifest "scope") permissions (into #{} (obj/get manifest "permissions" [])) permissions (cond-> permissions + ;; Global plugins must be explicitly allowed by the user + (= "global" scope) + (conj "allow:global") + (contains? permissions "content:write") (conj "content:read") @@ -102,7 +107,7 @@ {:plugin-id plugin-id :url (str plugin-url) :version vers - :scope (obj/get manifest "scope") + :scope scope :name name :description desc :host origin diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs index 55ddd1c4a6..49c387d4bc 100644 --- a/frontend/test/frontend_tests/plugins/register_test.cljs +++ b/frontend/test/frontend_tests/plugins/register_test.cljs @@ -320,3 +320,24 @@ (t/is (= [true false] @seen) "no calls after unsubscribing") (done')) done)))) + +;; --- parse-manifest --- + +(t/deftest parse-manifest-adds-allow-global-for-global-scope + (let [manifest (preg/parse-manifest + "http://localhost:4400/manifest.json" + #js {:name "Global plugin" + :code "plugin.js" + :scope "global" + :permissions #js ["content:read"]})] + (t/is (= "global" (:scope manifest))) + (t/is (= #{"content:read" "allow:global"} (:permissions manifest))))) + +(t/deftest parse-manifest-keeps-workspace-plugins-without-allow-global + (let [manifest (preg/parse-manifest + "http://localhost:4400/manifest.json" + #js {:name "Workspace plugin" + :code "plugin.js" + :permissions #js ["content:read"]})] + (t/is (nil? (:scope manifest))) + (t/is (= #{"content:read"} (:permissions manifest))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index d71621ab02..18000c059e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8180,6 +8180,12 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Start file downloads." +#: src/app/main/ui/workspace/plugins.cljs:339 +msgid "workspace.plugins.permissions.allow-global" +msgstr "" +"Keep running while you move between the dashboard and your files, and find " +"and open your projects and files." + #: src/app/main/ui/workspace/plugins.cljs:332 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Store data in the browser." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index c1464fe980..03bac196ed 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8276,6 +8276,12 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Comenzar descargas de ficheros." +#: src/app/main/ui/workspace/plugins.cljs:339 +msgid "workspace.plugins.permissions.allow-global" +msgstr "" +"Seguir funcionando mientras te mueves entre el panel y tus archivos, y " +"buscar y abrir tus proyectos y archivos." + #: src/app/main/ui/workspace/plugins.cljs:332 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Guardar datos en el navegador." diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index ab324f891c..041d252969 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -3,7 +3,7 @@ ### 🚀 Features - **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. -- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index aefc563c65..357fe9f95f 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -20,6 +20,7 @@ export const manifestSchema = z.object({ 'comment:write', 'allow:downloads', 'allow:localstorage', + 'allow:global', 'clipboard:read', 'clipboard:write', ]), From a125977fce0d4786224050a19a1b62adabfde627 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 13:24:17 +0200 Subject: [PATCH 063/325] :bug: Keep the MCP plugin working without penpotMgmt Penpot only exposes penpotMgmt to plugins running with global scope. Without it, the MCP plugin failed on load when registering its workspacechange listener. When penpotMgmt is missing, the plugin now builds the workspace metadata from the open file and skips the listener. execute_code receives penpotMgmt as null and waits for layout updates as before. The tool description and instructions tell agents that only the open file is available when penpotMgmt is null. --- .../plugin/src/ConnectionMetadata.test.ts | 38 +++++++++++++++++++ mcp/packages/plugin/src/index.d.ts | 3 +- mcp/packages/plugin/src/plugin.ts | 29 ++++++++++++-- .../task-handlers/ExecuteCodeTaskHandler.ts | 5 ++- .../server/data/initial_instructions.md | 2 + .../server/src/tools/ExecuteCodeTool.ts | 1 + 6 files changed, 72 insertions(+), 6 deletions(-) diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 91aadb8188..3131086153 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -192,6 +192,44 @@ test("plugin reads the current file for each connection metadata request", () => ]); }); +test("plugin without penpotMgmt derives metadata from the open file", () => { + let onMessage!: (message: any) => void; + const messages: any[] = []; + const penpot = { + currentFile: { id: "file-1", name: "Design" } as { id: string; name: string } | null, + currentUser: { sessionId: "tab-1" }, + theme: "dark", + ui: { + open() {}, + onMessage: (handler: (message: any) => void) => { + onMessage = handler; + }, + sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))), + }, + on() {}, + }; + runInNewContext(compile("plugin.ts"), { + exports: {}, + penpot, + mcp: undefined, + require: () => ({ ExecuteCodeTaskHandler: class {} }), + }); + onMessage({ type: "connection-metadata-request", sessionId: "first" }); + penpot.currentFile = null; + onMessage({ type: "context-request" }); + assert.deepEqual(messages, [ + { + type: "initialize", + penpotUserSessionId: "tab-1", + session: { sessionId: "first", fileId: "file-1", fileName: "Design", workspaceState: "ready" }, + }, + { + type: "context-update", + context: { fileId: null, fileName: null, workspaceState: "none" }, + }, + ]); +}); + test("integrated connections reuse the short session ID across reconnects", async () => { const ui = pluginUi(); ui.message({ type: "mcp-mode", integratedRemoteMcp: true }); diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index 67a498275f..b53afdef68 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -8,7 +8,8 @@ interface McpOptions { declare global { const mcp: undefined | McpOptions; - const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt; + /** management API; only exposed to plugins running with global scope */ + const penpotMgmt: undefined | null | import("../../../../plugins/libs/plugin-types").PenpotMgmt; } export {}; diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index bb55706a10..b3e8c691bc 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -9,6 +9,12 @@ import type { PluginConnectionInit } from "../../common/src"; */ const isIntegratedRemoteMcp = !!mcp; +/** + * the management API, which Penpot only exposes to plugins running with global scope; + * null otherwise (e.g. in Penpot versions without global plugins) + */ +const management = typeof penpotMgmt === "undefined" ? null : penpotMgmt; + /** * Extracts the major.minor.patch prefix from a version string. * @@ -66,7 +72,7 @@ penpot.ui.onMessage< }); } } else if (typeof message === "object" && message.type === "connection-metadata-request") { - const workspace = penpotMgmt.workspace; + const workspace = currentWorkspace(); if (message.sessionId) { const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = { type: "initialize", @@ -92,9 +98,26 @@ penpot.ui.onMessage< } }); +/** + * Provides the workspace metadata of this tab. + * Without the management API, the metadata is derived from the open file. + */ +function currentWorkspace(): NonNullable["workspace"] { + if (management) { + return management.workspace; + } + const file = penpot.currentFile; + return { + status: file ? "ready" : "none", + fileId: file?.id ?? null, + fileName: file?.name ?? null, + teamId: null, + }; +} + /** Sends the current workspace metadata without replacing the connection. */ function sendWorkspaceContext(): void { - const workspace = penpotMgmt.workspace; + const workspace = currentWorkspace(); penpot.ui.sendMessage({ type: "context-update", context: { @@ -105,7 +128,7 @@ function sendWorkspaceContext(): void { }); } -penpotMgmt.on("workspacechange", sendWorkspaceContext); +management?.on("workspacechange", sendWorkspaceContext); /** * Handles plugin task requests received from the MCP server via WebSocket. diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 2132446f80..47004e5cfe 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -178,7 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler { // initialize context, making penpot, penpotUtils, storage and the custom console available this.context = { penpot: penpot, - penpotMgmt: penpotMgmt, + penpotMgmt: typeof penpotMgmt === "undefined" ? null : penpotMgmt, storage: {}, console: new ExecuteCodeTaskConsole(), penpotUtils: PenpotUtils, @@ -217,8 +217,9 @@ export class ExecuteCodeTaskHandler extends TaskHandler { try { // wait for layout updates prior to executing the supplied code (if method is available) try { + const management = this.context.penpotMgmt; // @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types - if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) { + if ((!management || management.workspace.status === "ready") && penpot.waitForLayoutUpdate) { // @ts-ignore await penpot.waitForLayoutUpdate(); } diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 6fd27c2e81..437c58c7ab 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -441,6 +441,8 @@ Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: " Both creation methods require `content:write` and return metadata without changing the current view. To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. +If `penpotMgmt` is `null`, file management is unavailable and only the file that is open in Penpot can be used; +ask the user to open another file if needed. -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index 7a8e710e33..98932a154a 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -50,6 +50,7 @@ export class ExecuteCodeTool extends Tool { "IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " + "which Penpot API functionality is necessary and how to use it.\n" + "You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" + + "If `penpotMgmt` is null, file management is unavailable and only the open file can be used.\n" + "`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " + "stored attributes can be referenced in future calls to this tool, so any intermediate results that " + "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + From 9517f2ac9426f8058808f7df690acb23ad24d8f1 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 15:20:32 +0200 Subject: [PATCH 064/325] :recycle: Type the MCP plugin with the repo plugin types Link the MCP plugin to plugins/libs/plugin-types instead of the published 1.5.0 package, which lacks PenpotMgmt. The repo types declare the penpotMgmt global, so the inline import in index.d.ts goes away and the plugin checks against the API it runs with. Type Context.management as PenpotMgmt without on/off, so new management methods need no extra listing. Mark the background plugin mock in load-plugin.spec.ts as NonNullable: createPlugin can return undefined, and the build type-check failed on it. --- mcp/packages/plugin/package.json | 2 +- mcp/packages/plugin/src/index.d.ts | 2 -- mcp/packages/plugin/src/plugin.ts | 3 ++- mcp/packages/server/data/api_types.yml | 23 ++++--------------- mcp/pnpm-lock.yaml | 9 ++------ plugins/libs/plugin-types/index.d.ts | 14 ++++------- .../src/lib/load-plugin.spec.ts | 2 +- 7 files changed, 16 insertions(+), 39 deletions(-) diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 1a44059515..d4a3723755 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@penpot/plugin-styles": "1.5.0", - "@penpot/plugin-types": "1.5.0" + "@penpot/plugin-types": "link:../../../plugins/libs/plugin-types" }, "devDependencies": { "cross-env": "^10.1.0", diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts index b53afdef68..1fa11567bb 100644 --- a/mcp/packages/plugin/src/index.d.ts +++ b/mcp/packages/plugin/src/index.d.ts @@ -8,8 +8,6 @@ interface McpOptions { declare global { const mcp: undefined | McpOptions; - /** management API; only exposed to plugins running with global scope */ - const penpotMgmt: undefined | null | import("../../../../plugins/libs/plugin-types").PenpotMgmt; } export {}; diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index b3e8c691bc..025354df74 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -2,6 +2,7 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; import { formatTaskError } from "./ErrorUtils"; import type { PluginConnectionInit } from "../../common/src"; +import type { WorkspaceContext } from "@penpot/plugin-types"; /** * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server @@ -102,7 +103,7 @@ penpot.ui.onMessage< * Provides the workspace metadata of this tab. * Without the management API, the metadata is derived from the open file. */ -function currentWorkspace(): NonNullable["workspace"] { +function currentWorkspace(): WorkspaceContext { if (management) { return management.workspace; } diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 1fbccd549f..7fc050cec7 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5302,15 +5302,7 @@ Context: ``` interface Context { - management?: Pick< - PenpotMgmt, - "workspace" - | "openFile" - | "listProjects" - | "listFiles" - | "createProject" - | "createFile", - >; + management?: Omit; version: string; root: Shape | null; currentFile: File | null; @@ -5384,16 +5376,11 @@ Context: Properties: management: |- ``` - readonly management?: Pick< - PenpotMgmt, - "workspace" - | "openFile" - | "listProjects" - | "listFiles" - | "createProject" - | "createFile", - > + readonly management?: Omit ``` + + Backs `penpotMgmt`. Its events go through the plugin listener registry, + so `on` and `off` are not part of it. version: |- ``` readonly version: string diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 1ebfccbe6d..854a012147 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -188,8 +188,8 @@ importers: specifier: 1.5.0 version: 1.5.0 '@penpot/plugin-types': - specifier: 1.5.0 - version: 1.5.0 + specifier: link:../../../plugins/libs/plugin-types + version: link:../../../plugins/libs/plugin-types devDependencies: cross-env: specifier: ^10.1.0 @@ -663,9 +663,6 @@ packages: '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} - '@penpot/plugin-types@1.5.0': - resolution: {integrity: sha512-hFk4Rx66rLaSUWoN6bhxXkDI2ymwKg9xokdslvZvXM5MSmEmfhsJKUMwtuzgYWljbbfaQhK+DjK8g8yVbbavtA==} - '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1880,8 +1877,6 @@ snapshots: '@penpot/plugin-styles@1.5.0': {} - '@penpot/plugin-types@1.5.0': {} - '@pinojs/redact@0.4.0': {} '@rolldown/binding-android-arm-eabi@1.2.11': diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index f375f42cd3..031f7cc3ba 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -902,15 +902,11 @@ export interface PenpotMgmt { * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { - readonly management?: Pick< - PenpotMgmt, - | 'workspace' - | 'openFile' - | 'listProjects' - | 'listFiles' - | 'createProject' - | 'createFile' - >; + /** + * Backs `penpotMgmt`. Its events go through the plugin listener registry, + * so `on` and `off` are not part of it. + */ + readonly management?: Omit; /** * Returns the current penpot version. */ diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index eeffb3d236..853c1d12c7 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -216,7 +216,7 @@ describe('plugin-loader', () => { pluginId: 'background-plugin', allowBackground: true, }, - } as unknown as Awaited>; + } as unknown as NonNullable>>; vi.mocked(createPlugin).mockResolvedValue(backgroundPluginApi); await loadPlugin(manifest); From dcf0b197eb387279b56d629aa00a87b4c3b69956 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 7 Oct 2026 15:45:10 +0200 Subject: [PATCH 065/325] :bug: Fix inner shadow covering the shape in the SVG renderer (#12141) * :bug: Fix inner shadow covering the shape in the SVG renderer The svg filter components moved to the `*` syntax (#9448), but filter-entry* still built their props as a raw JS object with kebab-case keys. Raw JS props skip the kebab to camel conversion, so the components read nil for filterId and filterIn, and no filter primitive got its result or in2. With the chain broken, an inner shadow was blended over the empty flood instead of the shape, hiding the fill (and a board's children) under the shadow color. Exports using these filters broke the same way. Pass the props in camelCase. Closes #11967 AI-assisted-by: claude-opus-5-5 * :recycle: Build filter entry props with mf/props Use mf/props instead of a raw JS object so the props keep the kebab-case keys used across the codebase. The macro converts them to camelCase at compile time, so there is no runtime cost, and the comment about the manual conversion is no longer needed. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/shapes/filters.cljs | 6 +-- frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/ui/shape_filters_test.cljs | 47 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/shape_filters_test.cljs diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index a4468d649d..bf82f08be0 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -116,9 +116,9 @@ :result filter-id}]) (mf/defc filter-entry* [{:keys [entry]}] - (let [props #js {:filter-id (:id entry) - :filter-in (:filter-in entry) - :params (:params entry)}] + (let [props (mf/props {:filter-id (:id entry) + :filter-in (:filter-in entry) + :params (:params entry)})] (case (:type entry) :drop-shadow [:> drop-shadow-filter* props] :inner-shadow [:> inner-shadow-filter* props] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a17d9b3149..daaae968a0 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -124,6 +124,7 @@ [frontend-tests.ui.routes-test] [frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-shortcuts-test] + [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] [frontend-tests.ui.stroke-menu-test] @@ -273,6 +274,7 @@ 'frontend-tests.text-editor-paste-guard-test 'frontend-tests.ui.settings-password-schema-test 'frontend-tests.ui.settings-shortcuts-test + 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test 'frontend-tests.ui.stroke-menu-test diff --git a/frontend/test/frontend_tests/ui/shape_filters_test.cljs b/frontend/test/frontend_tests/ui/shape_filters_test.cljs new file mode 100644 index 0000000000..0cfc20b149 --- /dev/null +++ b/frontend/test/frontend_tests/ui/shape_filters_test.cljs @@ -0,0 +1,47 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.shape-filters-test + (:require + ["react-dom/server" :as rds] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.main.ui.shapes.filters :as filters] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- render-filter + [shadow-style] + (let [shape (-> (cts/setup-shape {:type :rect :x 0 :y 0 :width 100 :height 100}) + (assoc :fills [{:fill-color "#4488ff" :fill-opacity 1}] + :shadow [{:id (uuid/next) + :style shadow-style + :offset-x 4 :offset-y 4 :blur 4 :spread 0 + :hidden false + :color {:color "#000000" :opacity 0.5}}]))] + (rds/renderToStaticMarkup + (mf/element filters/filters* #js {:filterId "filter" :shape shape})))) + +(defn- tags + [markup tag] + (re-seq (re-pattern (str "<" tag "[^>]*>")) markup)) + +;; Each filter primitive must name its result and read the previous one, or +;; the chain breaks: an inner shadow then covers the shape fill with the +;; shadow color (#11967). + +(t/deftest inner-shadow-filter-chain-is-wired + (let [markup (render-filter :inner-shadow) + blends (tags markup "feBlend")] + (t/is (str/includes? (first (tags markup "feFlood")) "result=")) + (t/is (= 2 (count blends))) + (t/is (every? #(str/includes? % "in2=") blends)) + (t/is (every? #(str/includes? % "result=") blends)))) + +(t/deftest drop-shadow-filter-chain-is-wired + (let [blends (tags (render-filter :drop-shadow) "feBlend")] + (t/is (every? #(and (str/includes? % "in2=") (str/includes? % "result=")) blends)))) From f252439d843a20449421e5e2d8ca15a97eb12678 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Wed, 7 Oct 2026 18:19:34 +0200 Subject: [PATCH 066/325] :recycle: Factor GL context initialization out of canvas init (#11931) * :recycle: Factor GL context initialization out of canvas init - fixes setting of gl-context-handle to be after _init so an exception does not leave invalid state behind. - api.init-canvas-context uses a new webgl.init-context! - this decouples config from init, so we can reuse it in benchmarks - some helpers move to the webgl ns AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3 * :bug: Release acquired GL context when renderer init fails - Properly release resources if init fails - Rethrown exceptions with the original cause - Fix vacuous check in clear-canvas AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3 * :sparkles: Test webgl context init and release AI-assisted-by: muse-spark, glm-5.3 * :lipstick: Address review comments * :lipstick: Drop `!` from some function names --- .../common/render_wasm/serialize_shape.cljs | 6 +- exporter/src/app/wasm/serialize.cljs | 4 +- frontend/src/app/render_wasm/api.cljs | 146 ++++++------------ frontend/src/app/render_wasm/api/webgl.cljs | 136 +++++++++++++++- .../render_wasm/serialization_test.cljs | 10 +- .../render_wasm/webgl_test.cljs | 105 +++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 7 files changed, 300 insertions(+), 109 deletions(-) create mode 100644 frontend/test/frontend_tests/render_wasm/webgl_test.cljs diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index 1df4a84505..b97fc8599a 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -48,7 +48,7 @@ (when (and (contains? #{:path :bool} type) (some? (:content shape))) (props/set-shape-path-content (:content shape))))) -(defn serialize-shape! +(defn serialize-shape "Applies every host-independent WASM property of `shape`." [shape] (let [shape (svg-derived/apply-svg-derived shape)] @@ -59,11 +59,11 @@ shape)) -(defn serialize-shapes-batch! +(defn serialize-shapes-batch "Structural batch upload plus per-shape svg-attrs/path tail. Derives via svg-derived, uploads one `_set_shapes_batch` with `opts`, - then selects each shape needing svg-attrs/path content via `use-shape!` + then selects each shape needing svg-attrs/path content via `use-shape` and applies the tail. Host text/grid/image sequencing stays in callers. Returns the prepared vector for the downstream host-attrs loop." [shapes opts] diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs index 6f316187c7..93071eaa31 100644 --- a/exporter/src/app/wasm/serialize.cljs +++ b/exporter/src/app/wasm/serialize.cljs @@ -23,12 +23,12 @@ (defn set-shape! "Serializes a single shape into the WASM design state. The host-independent properties (base props, children, blur, shadows, svg-attrs, mask, bool-type, - path geometry, grow-type) go through the shared `serialize-shape!` — the same + path geometry, grow-type) go through the shared `serialize-shape` — the same code the workspace's `set-object` uses, so the two can't drift. Only the host-specific parts are handled here: fills/strokes (image bytes are provisioned separately) and text content (fonts provisioned separately)." [shape] - (let [prepared (serialize-shape/serialize-shape! shape) + (let [prepared (serialize-shape/serialize-shape shape) type (:type prepared)] (props/write-shape-fills! (:fills prepared)) (when-not (= type :group) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 59ad963352..93f840767e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -276,8 +276,6 @@ (def text-editor-select-paragraph text-editor/text-editor-select-paragraph) (def text-editor-sync-content text-editor/text-editor-sync-content) -(def dpr - (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (defn get-dpr "Returns the current device pixel ratio. Use instead of `dpr` wherever @@ -1132,7 +1130,7 @@ (h/call wasm/internal-module "_set_shape_hidden" hidden)) (defn clear-shape-fills! - "Clear the fills of the currently-selected shape (call `use-shape!` first). + "Clear the fills of the currently-selected shape (call `use-shape` first). Equivalent to `set-shape-fills` with an empty collection." [] (when (initialized?) @@ -1590,7 +1588,7 @@ `skip-fills-strokes?` is true, fill/stroke records were already in the batch; only image fetches remain. - Always `use-shape!` first: after a multi-shape batch the WASM current shape is + Always `use-shape` first: after a multi-shape batch the WASM current shape is the last record in the chunk, not this shape." [shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}] (let [id (dm/get-prop shape :id) @@ -1645,7 +1643,7 @@ {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} (do (perf/begin-measure "set-object") - (let [prepared (serialize-shape/serialize-shape! shape) + (let [prepared (serialize-shape/serialize-shape shape) result (set-object-host-attrs prepared false)] (perf/end-measure "set-object") result)))) @@ -1813,7 +1811,7 @@ end-index (min total (+ start-index BATCH_MAX_SHAPES)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) start-index end-index)) - prepared (serialize-shape/serialize-shapes-batch! + prepared (serialize-shape/serialize-shapes-batch chunk {:include-layout? true :include-fills-strokes? true})] @@ -1962,7 +1960,7 @@ (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." [shapes render-callback on-shapes-ready] - (let [prepared (serialize-shape/serialize-shapes-batch! + (let [prepared (serialize-shape/serialize-shapes-batch shapes {:include-layout? true :include-fills-strokes? true}) @@ -2336,13 +2334,6 @@ (fn [resolve _reject] (timers/raf (fn [] (resolve nil)))))) -(def ^:private default-context-options - #js {:antialias false - :depth true - :stencil true - :alpha true - "preserveDrawingBuffer" true}) - (defn resize-viewbox "Resizes the WASM viewbox. No-ops unless the GL context is live (`wasm/live?`), so callers cannot hit `_resize_viewbox` during @@ -2377,54 +2368,19 @@ (when (wasm/live?) (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr))) -(def ^:private max-surface-size - ;; Must match `gpu_state::MAX_SURFACE_SIZE`. - 8192) - -(defn- clamp-physical-size - "Clamp physical pixel dimensions before assigning `canvas.width/height`. - Rust `resize` applies the same cap and syncs the effective DPR from the - real drawing buffer." - [w h] - (let [w (mth/max 1 w) - h (mth/max 1 h) - scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))] - [(mth/max 1 (mth/floor (* scale w))) - (mth/max 1 (mth/floor (* scale h)))])) - (defn resize-offscreen-canvas! "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and update the WASM render surfaces accordingly (via `_resize_viewbox`). The design state (shape pool) is preserved so `set-objects` is not needed again." [canvas new-physical-w new-physical-h] (when (wasm/live?) - (let [dpr (get-dpr) - [pw ph] (clamp-physical-size new-physical-w new-physical-h)] + (let [dpr (get-dpr) + [pw ph] (webgl/clamp-physical-size new-physical-w new-physical-h)] (set! (.-width canvas) pw) (set! (.-height canvas) ph) (set-render-options! dpr) (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))))) -(defn- wasm-set-param-from-route-params-if-present - [param-name] - (when-let [value (wasm-get-numeric-value param-name)] - (let [setter-name (str/concat "_set_" (name param-name))] - (h/call wasm/internal-module setter-name value)))) - -(defn- canvas-css-size - "Return canvas size in CSS pixels. - - - For DOM canvases: use `clientWidth/clientHeight`. - - For OffscreenCanvas: fall back to `width/height` (physical px) converted by DPR." - [canvas dpr] - (let [cw (.-clientWidth ^js canvas) - ch (.-clientHeight ^js canvas)] - (if (and (number? cw) (pos? cw) - (number? ch) (pos? ch)) - [cw ch] - [(/ (.-width ^js canvas) dpr) - (/ (.-height ^js canvas) dpr)]))) - (defn resize-canvas! "Sizes the canvas drawing buffer, the WASM render surface and the DPR from a single source of truth (the canvas CSS client size) so the GL framebuffer @@ -2436,10 +2392,10 @@ (resize-canvas! canvas (get-dpr))) ([canvas new-dpr] (when (wasm/live?) - (let [[css-w css-h] (canvas-css-size canvas new-dpr) - css-w (mth/max 1 css-w) - css-h (mth/max 1 css-h) - [phys-w phys-h] (clamp-physical-size + (let [[css-w css-h] (webgl/canvas-css-size canvas new-dpr) + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) + [phys-w phys-h] (webgl/clamp-physical-size (mth/floor (* css-w new-dpr)) (mth/floor (* css-h new-dpr)))] (set! (.-width ^js canvas) phys-w) @@ -2503,45 +2459,40 @@ [canvas] (if-not (wasm/module-ready?) false - (let [gl (unchecked-get wasm/internal-module "GL") - flags (debug-flags) + (let [flags (debug-flags) context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2") - context (.getContext ^js canvas context-id default-context-options) - context-init? (not (nil? context)) - browser (sr/translate-browser cf/browser) - dpr (get-dpr) - [css-w css-h] (canvas-css-size canvas dpr) + browser (sr/translate-browser cf/browser) + dpr (get-dpr) + [css-w css-h] (webgl/canvas-css-size canvas dpr) ;; Avoid 0×0 Skia/GL surfaces (crashes on some browsers). - css-w (mth/max 1 css-w) - css-h (mth/max 1 css-h) + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) + params {:antialias_threshold (wasm-get-numeric-value :antialias_threshold) + :viewport_interest_area_threshold (wasm-get-numeric-value :viewport_interest_area_threshold) + :max_blocking_time_ms (wasm-get-numeric-value :max_blocking_time_ms) + :node_batch_threshold (wasm-get-numeric-value :node_batch_threshold) + :blur_downscale_threshold (wasm-get-numeric-value :blur_downscale_threshold)} + result (webgl/init-context canvas {:module wasm/internal-module + :context-id context-id + :css-width css-w + :css-height css-h + :dpr dpr + :flags flags + :browser browser + :params params}) + context-init? (some? result) can-listen? (fn? (.-addEventListener ^js canvas))] - (when-not (nil? context) - (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] - (.makeContextCurrent ^js gl handle) + (when result + (let [{:keys [context handle]} result] (set! wasm/gl-context-handle handle) (set! wasm/gl-context context) - ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it - (.getExtension context "WEBGL_debug_renderer_info") - - ;; Initialize Wasm Render Engine - (h/call wasm/internal-module "_init" css-w css-h) - (h/call wasm/internal-module "_set_render_options" flags dpr) - - ;; Configurable parameters. - (wasm-set-param-from-route-params-if-present :antialias_threshold) - (wasm-set-param-from-route-params-if-present :viewport_interest_area_threshold) - (wasm-set-param-from-route-params-if-present :max_blocking_time_ms) - (wasm-set-param-from-route-params-if-present :node_batch_threshold) - (wasm-set-param-from-route-params-if-present :blur_downscale_threshold) - - ;; Set browser after `_init`; mark live before sizing so the + ;; `init-context` already ran `_init` through `_set_browser` in + ;; order. Publish the handles, then mark live before sizing so the ;; guarded `resize-*` helpers can run (they require `wasm/live?`, ;; which is true here even while `reloading?` still blocks app callers). - (h/call wasm/internal-module "_set_browser" browser) - - ;; Add event listeners for WebGL context lost (set! wasm/canvas canvas) + ;; Add event listeners for WebGL context lost (when can-listen? (.addEventListener canvas "webglcontextlost" webgl-context-lost-listener) (.addEventListener canvas "webglcontextrestored" webgl-context-restored-listener)) @@ -2587,19 +2538,18 @@ ;; Ensure the WebGL context is properly disposed so browsers do not keep ;; accumulating active contexts between page switches. - (when-let [gl (unchecked-get wasm/internal-module "GL")] - (when-let [handle wasm/gl-context-handle] - (try - ;; For hard teardown we can explicitly lose browser context. - ;; For reload->reinit flows we skip this because immediate context - ;; recreation may fail on some browsers/GPUs while context is lost. - (when lose-browser-context? - (when-let [ctx wasm/gl-context] - (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] - (.loseContext ^js lose-ext)))) - (.deleteContext ^js gl handle) - (catch :default dispose-error - (.error js/console dispose-error))))) + (when-let [handle wasm/gl-context-handle] + (try + ;; For hard teardown we can explicitly lose browser context. + ;; For reload->reinit flows we skip this because immediate context + ;; recreation may fail on some browsers/GPUs while context is lost. + (when lose-browser-context? + (when-let [ctx wasm/gl-context] + (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] + (.loseContext ^js lose-ext)))) + (webgl/delete-context! wasm/internal-module handle) + (catch :default dispose-error + (.error js/console dispose-error)))) (wasm-gesture/reset-after-wasm-reload!) (wasm/reset-context-state!) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index 5fedee789f..3fe8a7b0ba 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -5,12 +5,142 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.webgl - "WebGL utilities for pixel capture and rendering" + "WebGL context creation and utilities for pixel capture and rendering" (:require [app.common.logging :as log] + [app.common.math :as mth] + [app.common.render-wasm.helpers :as h] [app.common.render-wasm.wasm :as wasm] [promesa.core :as p])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Context +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def default-context-options + "Shared WebGL2 context attributes" + #js {:antialias false + :depth true + :stencil true + :alpha true + :preserveDrawingBuffer true}) + +(def ^:const ^:private max-surface-size + ;; Must match `gpu_state::MAX_SURFACE_SIZE`. + 8192) + +(defn canvas-css-size + "Return canvas size in CSS pixels. + + - For DOM canvases: use `clientWidth/clientHeight`. + - For OffscreenCanvas: fall back to `width/height` (physical px) converted by DPR." + [canvas dpr] + (let [cw (.-clientWidth ^js canvas) + ch (.-clientHeight ^js canvas)] + (if (and (number? cw) (pos? cw) + (number? ch) (pos? ch)) + [cw ch] + [(/ (.-width ^js canvas) dpr) + (/ (.-height ^js canvas) dpr)]))) + +(defn clamp-physical-size + "Clamp physical pixel dimensions before assigning `canvas.width/height`. + Rust `resize` applies the same cap and syncs the effective DPR from the + real drawing buffer." + [w h] + (let [w (mth/max 1 w) + h (mth/max 1 h) + scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))] + [(mth/max 1 (mth/floor (* scale w))) + (mth/max 1 (mth/floor (* scale h)))])) + +(defn delete-context! + "Deletes an Emscripten GL context by handle.Never throws." + [module handle] + (when (some? handle) + (try + ;; We look the method up by string key bc dot access would let `:advanced` + ;; builds rename the Emscripten method + (let [gl (unchecked-get module "GL") + delete-context (unchecked-get gl "deleteContext")] + (when (fn? delete-context) + (.call ^js delete-context gl handle))) + (catch :default _)))) + +(defn release-context! + "Releases renderer state and a WebGL context owned by the caller. + `handle` may be nil if Emscripten registration failed. Each release is + attempted even when an earlier one throws; cleanup must preserve the + original initialization failure. Writes no global lifecycle state." + [module context handle] + (when (some? handle) + (try + (h/call module "_clean_up") + (catch :default _))) + (try + (when-let [ext (.getExtension ^js context "WEBGL_lose_context")] + (.loseContext ^js ext)) + (catch :default _)) + (delete-context! module handle)) + +(defn init-context + "Create a WebGL2 context on `canvas` and run the WASM renderer init sequence. + + Expects all values resolved by the caller. Writes no `wasm/` atoms and installs + no listeners. + + - `opts` has keys `module`, `context-id`, `css-width`, `css-height`, `dpr`, + `flags`, `browser`, and `params`. + - `module` is the Emscripten module object holding `GL`, `_init`, + `_set_render_options`, and `_set_browser`. `context-id` is `\"webgl2\"` + (or `\"fail\"` to force the debug failure path). + - `css-width` and `css-height` are CSS pixel sizes already probed and clamped + to at least 1. + - `params` maps tunable keywords to values (which pass to `_set_*` unchecked): + - `:antialias_threshold` + - `:viewport_interest_area_threshold` + - `:max_blocking_time_ms` + - `:node_batch_threshold` + - `:blur_downscale_threshold` + + Returns `{:context :handle}` or nil when `getContext` returns nil. + On failure, releases acquired resources before rethrowing. On success, + ownership transfers to the caller." + [canvas {:keys [module context-id css-width css-height dpr flags browser params]}] + (let [gl (unchecked-get module "GL") + context (.getContext ^js canvas context-id default-context-options)] + (when-not (nil? context) + (let [handle (volatile! nil)] + (try + (vreset! handle (.registerContext ^js gl context #js {"majorVersion" 2})) + (.makeContextCurrent ^js gl @handle) + + ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it + (.getExtension context "WEBGL_debug_renderer_info") + + ;; Initialize Wasm Render Engine + (h/call module "_init" css-width css-height) + (h/call module "_set_render_options" flags dpr) + + ;; Configurable parameters, fixed order to match the editor path. + (when-let [value (:antialias_threshold params)] + (h/call module "_set_antialias_threshold" value)) + (when-let [value (:viewport_interest_area_threshold params)] + (h/call module "_set_viewport_interest_area_threshold" value)) + (when-let [value (:max_blocking_time_ms params)] + (h/call module "_set_max_blocking_time_ms" value)) + (when-let [value (:node_batch_threshold params)] + (h/call module "_set_node_batch_threshold" value)) + (when-let [value (:blur_downscale_threshold params)] + (h/call module "_set_blur_downscale_threshold" value)) + + (h/call module "_set_browser" browser) + {:context context + :handle @handle} + (catch :default cause + (release-context! module context @handle) + (throw cause))))))) + (defn get-webgl-context "Gets the WebGL context from the WASM module" [] @@ -23,6 +153,10 @@ (when current-ctx (.-GLctx ^js current-ctx))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Bitmap rendering +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn create-webgl-texture-from-image "Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object" [gl image-element] diff --git a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs index cb5438d1ce..b204bc13b6 100644 --- a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.serialization-test - "Routing tests for `serialize-shape!` and `serialize-shapes-batch!`. + "Routing tests for `serialize-shape` and `serialize-shapes-batch`. A shared `tail-cases` table states, per shape, whether the svg-attrs/path tail must fire. The pure `needs-shape-tail?` predicate is tested against @@ -23,7 +23,7 @@ (defn- with-ffi-stubs* "Stubs the single-arity FFI boundary fns (`flush-shapes-batch!`, - `use-shape!`, `set-shape-svg-attrs`, `set-shape-path-content`). + `use-shape`, `set-shape-svg-attrs`, `set-shape-path-content`). `set-shape-upload!` is deliberately left real: it is a trivial one-liner over the stubbed flush, so the single test exercises its actual delegation and default opts. A plain fn `set!` onto a multi-arity var breaks under @@ -121,7 +121,7 @@ :svg-attrs (fn [attrs] (swap! events conj [:svg-attrs @current attrs]) nil) :path (fn [content] (swap! events conj [:path @current content]) nil)} result (with-ffi-stubs* stubs - #(serialize-shape/serialize-shapes-batch! shapes opts)) + #(serialize-shape/serialize-shapes-batch shapes opts)) flushed (first @flush-calls)] (t/is (= 1 (count @flush-calls)) "exactly one flush") (t/is (= opts (:opts flushed)) "flush carries passed opts") @@ -140,7 +140,7 @@ :svg-attrs (fn [attrs] (swap! svg-applied conj attrs) nil) :path (fn [content] (swap! path-applied conj content) nil)}] (let [expected (svg-derived/apply-svg-derived shape) - result (with-ffi-stubs* stubs #(serialize-shape/serialize-shape! shape))] + result (with-ffi-stubs* stubs #(serialize-shape/serialize-shape shape))] (t/is (= [{:shapes [expected] :opts {:include-layout? false}}] @flush-calls) (str "single upload carries derived shape without layout for " (:type shape))) (t/is (= expected result) @@ -161,7 +161,7 @@ :svg-attrs (fn [_] (t/is false "no svg-attrs write on empty batch") nil) :path (fn [_] (t/is false "no path write on empty batch") nil)} result (with-ffi-stubs* stubs - #(serialize-shape/serialize-shapes-batch! [] opts))] + #(serialize-shape/serialize-shapes-batch [] opts))] (t/is (= [] result) "returns empty prepared") (t/is (empty? @flush-calls) "no flush on empty batch") (t/is (empty? @selected) "no select on empty batch"))) diff --git a/frontend/test/frontend_tests/render_wasm/webgl_test.cljs b/frontend/test/frontend_tests/render_wasm/webgl_test.cljs new file mode 100644 index 0000000000..beb60abce9 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/webgl_test.cljs @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-wasm.webgl-test + "Unit tests for `app.render-wasm.api.webgl` context init and release." + (:require + [app.render-wasm.api.webgl :as webgl] + [cljs.test :as t :include-macros true])) + +(defn- fake-env + "Builds `{canvas context module calls}` fakes. + - `calls` records `:register`, `:delete`, `:clean` and `:lose` events. + - Options: + - `:throw-in` makes that module fn throw (`:_init`, `:_set_browser`, ...), + - `:register-throws?` fails Emscripten registration (no handle exists), + - `:nil-context?` makes `getContext` return nil (nothing is acquired)." + [{:keys [throw-in register-throws? nil-context?]}] + (let [calls (atom []) + lose (fn [] (swap! calls conj [:lose]) nil) + context #js {:getExtension (fn [name] + (when (= name "WEBGL_lose_context") + #js {:loseContext lose}))} + gl #js {:registerContext (fn [_ _] + (swap! calls conj [:register]) + (when register-throws? + (throw (js/Error. "register failed"))) + 7) + :makeContextCurrent (fn [_] nil) + :deleteContext (fn [handle] + (swap! calls conj [:delete handle]) + nil)} + thrower (fn [name] + (fn [& _] + (when (= name throw-in) + (throw (js/Error. (str "fake " (pr-str name) " failure")))) + nil)) + module #js {:GL gl + :_init (thrower :_init) + :_set_render_options (thrower :_set_render_options) + :_set_browser (thrower :_set_browser) + :_clean_up (fn [& _] (swap! calls conj [:clean]) nil)} + canvas #js {:getContext (fn [_ _] (when-not nil-context? context))}] + {:calls calls :canvas canvas :context context :module module})) + +(defn- init-opts + [module] + {:module module + :context-id "webgl2" + :css-width 100 + :css-height 100 + :dpr 1 + :flags 0 + :browser 0 + :params {}}) + +(defn- init-failure + "Runs `init-context` against `env`, returning the thrown cause." + [{:keys [canvas module]}] + (try + (webgl/init-context canvas (init-opts module)) + nil + (catch :default cause + cause))) + +(defn- effect-count + [calls effect] + (count (filter #(= effect (first %)) calls))) + +(t/deftest init-failure-releases-the-acquired-context + (let [{:keys [canvas module calls]} (fake-env {:throw-in :_set_browser}) + failure (init-failure {:canvas canvas :module module})] + (t/is (some? failure) "the original failure propagates") + (t/is (= "_set_browser" (:fn (ex-data failure))) + "the cause keeps the failing fn") + (t/is (= 1 (effect-count @calls :clean)) "renderer state is released") + (t/is (some #{[:delete 7]} @calls) "the registered handle is deleted") + (t/is (some #{[:lose]} @calls) "the browser context is released"))) + +(t/deftest init-success-returns-context-and-handle + (let [{:keys [canvas context module calls]} (fake-env {}) + result (webgl/init-context canvas (init-opts module))] + (t/is (= 7 (:handle result))) + (t/is (identical? context (:context result))) + (t/is (zero? (effect-count @calls :clean)) "no release on success") + (t/is (zero? (effect-count @calls :delete)) "no release on success") + (t/is (zero? (effect-count @calls :lose)) "no release on success"))) + +(t/deftest nil-context-returns-nil-without-releasing + (let [{:keys [canvas module calls]} (fake-env {:nil-context? true})] + (t/is (nil? (webgl/init-context canvas (init-opts module)))) + (t/is (empty? @calls) "nothing acquired, nothing released"))) + +(t/deftest registration-failure-releases-browser-context-only + (let [{:keys [canvas module calls]} (fake-env {:register-throws? true}) + failure (init-failure {:canvas canvas :module module})] + (t/is (some? failure) "the original failure propagates") + (t/is (zero? (effect-count @calls :clean)) + "no renderer state exists without registration") + (t/is (zero? (effect-count @calls :delete)) + "no handle exists to delete") + (t/is (= 1 (effect-count @calls :lose)) + "the browser context is still released"))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index daaae968a0..df7409c045 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -90,6 +90,7 @@ [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.render-wasm.text-paste-test] + [frontend-tests.render-wasm.webgl-test] [frontend-tests.router-test] [frontend-tests.svg-fills-test] [frontend-tests.svg-filters-test] @@ -239,6 +240,7 @@ 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.render-wasm.text-paste-test + 'frontend-tests.render-wasm.webgl-test 'frontend-tests.router-test 'frontend-tests.svg-fills-test 'frontend-tests.svg-filters-test From cb1118f9045c83784008e2d8ae112abbb3f99320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 7 Oct 2026 18:20:03 +0200 Subject: [PATCH 067/325] :tada: Preserve Penpot's internal formatting when pasting (v3) (#12137) * :tada: Copy v3 editor text with its styles in a payload Copy and cut in the v3 text editor put the styled selection in a hidden span of text/html. Add its codec and the clean-up for pasting into another file or team. Refs #12047 AI-assisted-by: claude-opus-5-5 * :tada: Paste text copied in Penpot at the v3 editor caret Text copied in Penpot keeps its styles when pasted at the caret. The first paragraph keeps the target's attrs; later ones bring their own. The context menu paste now shares the keyboard paste path. Refs #12047 AI-assisted-by: claude-opus-5-5 * :tada: Paste text copied in Penpot on the canvas with its styles Refs #12047 AI-assisted-by: claude-opus-5-5 * :tada: Paste copied text shapes at the v3 editor caret with styles Copied shapes now record their team. Shapes that are not texts paste nothing into the editor. Refs #12047 AI-assisted-by: claude-opus-5-5 * :sparkles: Replace custom fonts in shapes pasted across teams Refs #12047 AI-assisted-by: claude-opus-5-5 --- .../app/main/data/workspace/clipboard.cljs | 27 ++- .../src/app/main/data/workspace/texts.cljs | 189 +++++++++++++++--- .../ui/workspace/shapes/text/v3_editor.cljs | 136 ++----------- frontend/src/app/render_wasm/api.cljs | 10 +- frontend/src/app/render_wasm/text_editor.cljs | 66 ++++++ frontend/src/app/render_wasm/text_paste.cljs | 38 +++- frontend/src/app/util/clipboard.cljs | 46 ++++- frontend/src/app/util/text/clipboard.cljs | 101 +++++++++- .../text_editor_content_range_test.cljs | 56 ++++++ .../render_wasm/text_paste_test.cljs | 46 +++++ frontend/test/frontend_tests/runner.cljs | 2 + .../util_text_clipboard_test.cljs | 93 +++++++++ 12 files changed, 638 insertions(+), 172 deletions(-) create mode 100644 frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 4d365f0bee..557747d080 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -213,6 +213,7 @@ :features features :version version :file-id file-id + :team-id (:current-team-id state) :selected selected :objects {} :images #{}} @@ -578,6 +579,7 @@ [:features ::sm/set-of-strings] [:version :int] [:file-id ::sm/uuid] + [:team-id {:optional true} ::sm/uuid] [:selected ::sm/set-of-uuid] [:objects [:map-of ::sm/uuid :map]] @@ -883,7 +885,7 @@ (assoc change :index (get map-ids (:old-id change))) change))) - (process-shape [valid-file-ids frame-id parent-id shape] + (process-shape [valid-file-ids other-team? frame-id parent-id shape] (cond-> shape :always (assoc :frame-id frame-id :parent-id parent-id) @@ -894,7 +896,11 @@ (assoc :shapes []) (cfh/text-shape? shape) - (ctt/remove-external-typographies valid-file-ids)))] + (ctt/remove-external-typographies valid-file-ids) + + ;; Custom font ids differ between teams. + (and other-team? (cfh/text-shape? shape)) + (update :content text-clipboard/replace-custom-fonts)))] (ptk/reify ::paste-shapes ptk/WatchEvent @@ -947,7 +953,11 @@ valid-file-ids (conj (set (keys libraries)) file-id) - objects (update-vals objects (partial process-shape valid-file-ids frame-id parent-id)) + ;; Shapes copied before the team was recorded count as same team. + other-team? (and (some? (:team-id pdata)) + (not= (:team-id pdata) (:current-team-id state))) + + objects (update-vals objects (partial process-shape valid-file-ids other-team? frame-id parent-id)) all-objects (merge page-objects objects) @@ -1067,10 +1077,13 @@ (watch [_ state _] (let [[text content] (if (v3-html-paste? state) - (when-let [fragment (some-> (text-clipboard/html->fragment html) - (text-clipboard/without-overrides))] - [(text-paste/fragment->text fragment) - (text-paste/fragment->content fragment (txt/get-default-text-attrs))]) + (if-let [payload (text-clipboard/html->payload html)] + (let [content (dwtxt/payload-content state payload)] + [(txt/content->text content) content]) + (when-let [fragment (some-> (text-clipboard/html->fragment html) + (text-clipboard/without-overrides))] + [(text-paste/fragment->text fragment) + (text-paste/fragment->content fragment (txt/get-default-text-attrs))])) (let [style (deref refs/workspace-clipboard-style) root (dwtxt/create-root-from-html html style (features/active-feature? @st/state "text-editor/v2-html-paste"))] [(.-textContent root) (tc/dom->cljs root)]))] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 4ddaf0eded..1040ec1fd6 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -10,12 +10,14 @@ [app.common.attrs :as attrs] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] [app.common.math :as mth] + [app.common.transit :as t] [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] @@ -42,8 +44,10 @@ [app.render-wasm.api :as wasm.api] [app.render-wasm.api.fonts :as wasm.fonts] [app.render-wasm.text-editor :as wasm.text-editor] + [app.render-wasm.text-paste :as text-paste] [app.util.clipboard :as clipboard] [app.util.text-editor :as ted] + [app.util.text.clipboard :as text-clipboard] [app.util.text.content :as tc] [app.util.text.content.styles :as styles] [app.util.timers :as ts] @@ -1419,18 +1423,34 @@ (gsh/transform-shape (ctm/change-size shape width height)))))) {:undo-group (when new-shape? id)})))))))) +(defn- editor-content-update + "Event saving `content` from the WASM editor, renaming the shape after its text." + [shape-id content & {:keys [finalize?]}] + (let [text (txt/content->text content) + name (when (not= text "") + (txt/generate-shape-name text))] + (v2-update-text-shape-content shape-id content + :update-name? true + :name name + :finalize? finalize?))) + (defn v3-sync-editor-content "Event pushing the WASM editor content back into the shape, or nil when there is nothing to sync. Every text edit commits through it, menu or keystroke alike." [& {:keys [finalize?]}] (when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)] - (let [text (txt/content->text content) - name (when (not= text "") - (txt/generate-shape-name text))] - (v2-update-text-shape-content shape-id content - :update-name? true - :name name - :finalize? finalize?)))) + (editor-content-update shape-id content :finalize? finalize?))) + +(defn v3-apply-pending-caret-styles + "Event saving an insertion made at `before` that consumed the pending caret style: + the inserted text gets that style as its own span. Nil when nothing changed." + [shape-id before] + (let [range (wasm.text-editor/typed-range before (wasm.text-editor/caret-position))] + ;; Sync first so the cached content stays index-aligned with WASM. + (wasm.text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] + (editor-content-update shape-id content) + (v3-sync-editor-content)))) (defn- sync-editor-content-stream "Stream of the sync event for `reason`, after asking WASM to repaint." @@ -1441,19 +1461,23 @@ (rx/of event) (rx/empty)))) -(defn- editor-selected-text - "Plain text of the current WASM editor selection, or nil when there is none." - [] +(defn editor-selection-clipboard-data + "Clipboard items for the WASM editor selection, or nil when nothing is selected. + The HTML also carries the styled content for pasting it in Penpot." + [state] (when (and (wasm.text-editor/text-editor-has-focus?) (wasm.text-editor/text-editor-has-selection?)) (let [text (wasm.text-editor/text-editor-export-selection)] - (when (seq text) text)))) - -(defn- write-selection-to-clipboard - "Write `text` as plain text and HTML; Windows apps often prefer CF_HTML." - [text] - (clipboard/to-clipboard-multi {"text/plain" text - "text/html" (clipboard/plain-text->html text)})) + (when (seq text) + (let [content (when (features/active-feature? state "text-editor-wasm/v1-html-paste") + (-> (wasm.text-editor/text-editor-get-active-shape-id) + (wasm.text-editor/selection-content))) + payload (when (some? content) + (text-clipboard/payload->html content + (:current-file-id state) + (:current-team-id state)))] + {"text/plain" text + "text/html" (clipboard/plain-text->html text payload)}))))) (defn- on-clipboard-error [cause] @@ -1471,9 +1495,9 @@ [] (ptk/reify ::v3-copy-selection ptk/WatchEvent - (watch [_ _ _] - (if-let [text (editor-selected-text)] - (->> (rx/from (write-selection-to-clipboard text)) + (watch [_ state _] + (if-let [data (editor-selection-clipboard-data state)] + (->> (rx/from (clipboard/to-clipboard-multi data)) (rx/ignore) (rx/catch on-clipboard-error)) (rx/empty))))) @@ -1483,9 +1507,9 @@ [] (ptk/reify ::v3-cut-selection ptk/WatchEvent - (watch [_ _ _] - (if-let [text (editor-selected-text)] - (->> (rx/from (write-selection-to-clipboard text)) + (watch [_ state _] + (if-let [data (editor-selection-clipboard-data state)] + (->> (rx/from (clipboard/to-clipboard-multi data)) (rx/mapcat (fn [_] ;; Delete only once the text is safely on the clipboard, ;; so a refused clipboard cannot lose the selection. @@ -1494,23 +1518,122 @@ (rx/catch on-clipboard-error)) (rx/empty))))) +;; Bigger HTML is pasted as plain text rather than walked. +(def ^:private max-paste-html-length 1000000) + +(defn payload-content + "The content of a Penpot text `payload` without what the current file cannot reach. + A payload without team counts as coming from the current team." + [state {:keys [content file-id team-id]}] + (let [current-file-id (:current-file-id state)] + (text-clipboard/clean-content + content + {:valid-file-ids (conj (set (keys (dsh/lookup-libraries state))) current-file-id) + :same-file? (= file-id current-file-id) + :same-team? (or (nil? team-id) (= team-id (:current-team-id state)))}))) + +(defn- decode-copied-shapes + "The copied shapes data in clipboard `text`, or nil when it holds something else." + [text] + (when (str/starts-with? (str/trim (or text "")) "{") + (let [data (ex/ignoring (t/decode-str text))] + (when (and (map? data) (= :copied-shapes (:type data))) + data)))) + +(defn- copied-shapes->payload + "A text payload joining the content of the copied text shapes, top to bottom and + left to right; nil when none of the copied shapes is a text." + [{:keys [objects selected file-id team-id]}] + (let [texts (->> selected + (keep #(get objects %)) + (filter cfh/text-shape?) + (sort-by (juxt :y :x)))] + (when (seq texts) + {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))}))) + +(defn- penpot-paste + [state payload] + {:penpot? true + :fragment (text-paste/content->fragment (payload-content state payload))}) + +(defn- clipboard->paste + "What to paste for clipboard `html` and `text`, or nil: text and text shapes copied + in Penpot keep their styles; any other text takes the caret style." + [state html text] + (let [html-paste? (features/active-feature? state "text-editor-wasm/v1-html-paste") + payload (when html-paste? (text-clipboard/html->payload html)) + shapes (when (and html-paste? (nil? payload)) (decode-copied-shapes text))] + (cond + (some? payload) + (penpot-paste state payload) + + (some? shapes) + (some->> (copied-shapes->payload shapes) (penpot-paste state)) + + :else + (when-let [fragment (or (when (and html-paste? (< 0 (count html) max-paste-html-length)) + (some-> (text-clipboard/html->fragment html) + (text-clipboard/without-overrides))) + (text-clipboard/text->fragment text))] + {:penpot? false + :fragment fragment})))) + +(defn- insert-fragment + "Insert the text of `fragment` at the caret and restyle it: with its own styles + or overrides, else with the pending caret style. Returns the event saving it." + [{:keys [fragment penpot?]}] + (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) + start (wasm.text-editor/selection-start) + styled? (or penpot? (text-paste/styled? fragment)) + pending? (some? (wasm.text-editor/get-pending-caret-styles shape-id))] + (when styled? + (wasm.text-editor/clear-pending-caret-styles!)) + (wasm.text-editor/text-editor-insert-text (text-paste/fragment->text fragment)) + (cond + (nil? start) + (v3-sync-editor-content) + + styled? + (do + ;; Sync first so the cached content stays index-aligned with WASM. + (wasm.text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] + (editor-content-update shape-id content) + (v3-sync-editor-content))) + + pending? + (v3-apply-pending-caret-styles shape-id start) + + :else + (v3-sync-editor-content)))) + +(defn v3-paste + "Paste clipboard `html` and `text` at the caret, replacing the selection." + [html text] + (ptk/reify ::v3-paste + ptk/WatchEvent + (watch [_ state _] + (if-let [paste (when (wasm.text-editor/text-editor-has-focus?) + (clipboard->paste state html text))] + (let [event (insert-fragment paste)] + (wasm.api/request-render-preserving-target "text-paste") + (if (some? event) (rx/of event) (rx/empty))) + (rx/empty))))) + (defn v3-paste-text - "Insert the system clipboard text at the caret, replacing the selection." + "Paste the system clipboard at the caret, replacing the selection." [] (ptk/reify ::v3-paste-text ptk/WatchEvent (watch [_ _ _] (if-not (wasm.text-editor/text-editor-has-focus?) (rx/empty) - (->> (rx/from (clipboard/read-text)) - (rx/mapcat (fn [text] - (if (seq text) - (do - ;; Pasted text keeps the surrounding style. - (wasm.text-editor/clear-pending-caret-styles!) - (wasm.text-editor/text-editor-insert-text text) - (sync-editor-content-stream "text-paste")) - (rx/empty)))) + (->> (rx/from (clipboard/read-html-and-text)) + (rx/map (fn [{:keys [html text]}] (v3-paste html text))) (rx/catch on-clipboard-error)))))) (defn v3-select-all diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index f3138a5238..b99b866d6d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -9,23 +9,18 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.types.text :as txt] [app.config :as cf] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.undo :as dwu] - [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.css-cursors :as cur] [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as text-editor] - [app.render-wasm.text-paste :as text-paste] - [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.keyboard :as kbd] - [app.util.text.clipboard :as text-clipboard] [app.util.timers :as ts] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -63,97 +58,19 @@ "Home" "End" "PageUp" "PageDown" "Enter" "Backspace" "Delete" "Escape" "Tab"}) -(defn- caret-position - "Collapsed caret as {:para :offset} from the WASM selection, or nil." - [] - (when-let [{:keys [focus-para focus-offset]} (text-editor/text-editor-get-selection)] - {:para focus-para :offset focus-offset})) - -(defn- typed-range - "Normalized range covering the text inserted between `before` and `after`, or nil." - [before after] - (when (and before after) - (if (or (< (:para before) (:para after)) - (and (= (:para before) (:para after)) - (<= (:offset before) (:offset after)))) - {:start-para (:para before) :start-offset (:offset before) - :end-para (:para after) :end-offset (:offset after)} - {:start-para (:para after) :start-offset (:offset after) - :end-para (:para before) :end-offset (:offset before)}))) - -(defn- commit-restyled-content - "Save `content` restyled after an insertion, renaming the shape after its text." - [shape-id content] - (let [text (txt/content->text content) - name (when (not= text "") (txt/generate-shape-name text))] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :name name)))) - (defn- sync-with-pending-caret-styles! - "Commit an insertion that consumed a pending caret style: sync the new text, - then restyle the just-typed `range` into its own span. `before` is the + "Commit an insertion that consumed a pending caret style. `before` is the pre-insert caret." [shape-id before] - (let [range (typed-range before (caret-position))] - ;; Sync first so the cached content stays index-aligned with WASM. - (text-editor/text-editor-sync-content) - (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] - (commit-restyled-content shape-id content) - (sync-wasm-text-editor-content!)))) + (when-let [event (dwt/v3-apply-pending-caret-styles shape-id before)] + (st/emit! event))) -;; Bigger HTML is pasted as plain text rather than walked. -(def ^:private max-paste-html-length 1000000) - -(defn- clipboard->fragment - "The paste fragment for `data`: its HTML when allowed and it has text, else its - plain text. Either way it is unstyled, so it adopts the caret style." - [^js data html-paste?] - (let [html (when html-paste? (.getData data "text/html"))] - (or (when (< 0 (count html) max-paste-html-length) - (some-> (text-clipboard/html->fragment html) - (text-clipboard/without-overrides))) - (text-clipboard/text->fragment (.getData data "text/plain"))))) - -(defn- selection-start - "Start of the WASM selection as {:para :offset}: where pasted text goes." - [] - (when-let [{:keys [anchor-para anchor-offset focus-para focus-offset]} - (text-editor/text-editor-get-selection)] - (if (or (< anchor-para focus-para) - (and (= anchor-para focus-para) (<= anchor-offset focus-offset))) - {:para anchor-para :offset anchor-offset} - {:para focus-para :offset focus-offset}))) - -(defn- paste-fragment - "Insert the text of `fragment`, then restyle it with the fragment overrides or, - when it has none, with the pending caret style." - [fragment] - (let [shape-id (text-editor/text-editor-get-active-shape-id) - start (selection-start) - styled? (text-paste/styled? fragment) - pending? (some? (text-editor/get-pending-caret-styles shape-id))] - (when styled? - (text-editor/clear-pending-caret-styles!)) - (text-editor/text-editor-insert-text (text-paste/fragment->text fragment)) - (cond - (nil? start) - (sync-wasm-text-editor-content!) - - styled? - (do - ;; Sync first so the cached content stays index-aligned with WASM. - (text-editor/text-editor-sync-content) - (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start)] - (commit-restyled-content shape-id content) - (sync-wasm-text-editor-content!))) - - pending? - (sync-with-pending-caret-styles! shape-id start) - - :else - (sync-wasm-text-editor-content!)))) +(defn- write-clipboard-items + "Write `items` (mime type -> string) to the DataTransfer of a copy or cut event. + Windows apps prefer text/html, or they may paste the editor's empty `
`." + [^js data items] + (doseq [[mime value] items] + (.setData data mime value))) (defn- reset-input-node "Empties the contenteditable capture surface and restores a collapsed caret @@ -331,11 +248,9 @@ (mf/use-fn (fn [^js event] (dom/prevent-default event) - (when-let [fragment (some-> (.-clipboardData event) - (clipboard->fragment - (features/active-feature? @st/state "text-editor-wasm/v1-html-paste")))] - (paste-fragment fragment) - (wasm.api/request-render-preserving-target "text-paste")) + (when-let [data (.-clipboardData event)] + (st/emit! (dwt/v3-paste (.getData data "text/html") + (.getData data "text/plain")))) (reset-input-node (mf/ref-val contenteditable-ref)))) on-copy @@ -343,30 +258,19 @@ (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-has-selection?) - (let [text (or (text-editor/text-editor-export-selection) "") - html (clipboard/plain-text->html text) - data (.-clipboardData event)] - ;; text/html matters on Windows: many apps prefer CF_HTML, and - ;; without it they can pick up the empty contenteditable `
`. - (.setData data "text/plain" text) - (.setData data "text/html" html)))))) + (when-let [items (dwt/editor-selection-clipboard-data @st/state)] + (write-clipboard-items (.-clipboardData event) items))))) on-cut (mf/use-fn (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-has-selection?) - (let [text (or (text-editor/text-editor-export-selection) "") - html (clipboard/plain-text->html text) - data (.-clipboardData event)] - (.setData data "text/plain" text) - (.setData data "text/html" html) - (when (seq text) - (text-editor/text-editor-delete-backward) - (sync-wasm-text-editor-content!) - (wasm.api/request-render-preserving-target "text-cut")))) + (when-let [items (dwt/editor-selection-clipboard-data @st/state)] + (write-clipboard-items (.-clipboardData event) items) + (text-editor/text-editor-delete-backward) + (sync-wasm-text-editor-content!) + (wasm.api/request-render-preserving-target "text-cut")) (reset-input-node (mf/ref-val contenteditable-ref))))) on-key-down @@ -515,7 +419,7 @@ (let [shape-id (text-editor/text-editor-get-active-shape-id) ;; The inserted character adopts a pending caret style, if any. pending-styles? (some? (text-editor/get-pending-caret-styles shape-id)) - before (when pending-styles? (caret-position))] + before (when pending-styles? (text-editor/caret-position))] (text-editor/text-editor-insert-text data) (if pending-styles? (sync-with-pending-caret-styles! shape-id before) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 93f840767e..d85571e1f0 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -805,11 +805,13 @@ result))) (defn apply-paste-styles - "Restyle the text just pasted at `start` with the overrides of `fragment`; - returns {:shape-id :content}, or nil when the shape has no cached content." - [shape-id fragment start] + "Restyle the text just pasted at `start` with `fragment`: its overrides, or its full + styles when copied in Penpot. Nil when the shape has no cached content." + [shape-id fragment start penpot?] (when-let [content (text-editor/get-cached-content shape-id)] - (let [content (text-paste/apply-fragment-styles content fragment start)] + (let [content (if penpot? + (text-paste/apply-content-styles content fragment start) + (text-paste/apply-fragment-styles content fragment start))] (wselect/use-shape shape-id) (set-shape-text-content shape-id content) (request-render "apply-paste-styles") diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 6493128aba..eff80ba610 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -670,6 +670,26 @@ {:start-para focus-para :start-offset focus-offset :end-para anchor-para :end-offset anchor-offset})) +(defn caret-position + "Collapsed caret as {:para :offset} from the WASM selection, or nil." + [] + (when-let [{:keys [focus-para focus-offset]} (text-editor-get-selection)] + {:para focus-para :offset focus-offset})) + +(defn selection-start + "Start of the WASM selection as {:para :offset}: where inserted text goes." + [] + (when-let [selection (text-editor-get-selection)] + (let [{:keys [start-para start-offset]} (normalize-selection selection)] + {:para start-para :offset start-offset}))) + +(defn typed-range + "Normalized range covering the text inserted between `before` and `after`, or nil." + [before after] + (when (and before after) + (normalize-selection {:anchor-para (:para before) :anchor-offset (:offset before) + :focus-para (:para after) :focus-offset (:offset after)}))) + (defn apply-attrs-to-paragraph "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans." [para sel-start sel-end styles] @@ -772,6 +792,52 @@ paragraphs)] (assoc content :children [(assoc paragraph-set :children new-paragraphs)]))) +(defn- slice-paragraph + "`para` with only the text in [start, end), cutting the spans at both ends. + With no text left it keeps its first span, empty, for the style." + [para start end] + (let [spans (loop [spans (:children para) + pos 0 + acc []] + (if (empty? spans) + acc + (let [span (first spans) + text (:text span) + span-end (+ pos (count text)) + ol-start (max pos start) + ol-end (min span-end end)] + (recur (rest spans) span-end + (cond-> acc + (< ol-start ol-end) + (conj (assoc span :text (subs text (- ol-start pos) (- ol-end pos)))))))))] + (assoc para :children (if (seq spans) + spans + [(assoc (first (:children para)) :text "")])))) + +(defn content-range + "The part of `content` inside the char range, with its paragraph and span styles." + [content {:keys [start-para start-offset end-para end-offset]}] + (let [paragraph-set (first (:children content)) + paragraphs (->> (:children paragraph-set) + (map-indexed vector) + (keep (fn [[idx para]] + (when (<= start-para idx end-para) + (slice-paragraph para + (if (= idx start-para) start-offset 0) + (if (= idx end-para) end-offset (para-char-count para)))))) + (vec))] + (assoc content :children [(assoc paragraph-set :children paragraphs)]))) + +(defn selection-content + "The content of the editor selection in `shape-id`, or nil when it is collapsed." + [shape-id] + (when-let [selection (text-editor-get-selection)] + (let [{:keys [start-para start-offset end-para end-offset] :as range} + (normalize-selection selection)] + (when-let [content (get-cached-content shape-id)] + (when-not (and (= start-para end-para) (= start-offset end-offset)) + (content-range content range)))))) + (defn- clean-styles "Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail the backend schema); a per-span fn is passed through untouched." diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs index afb72674f6..dbdd6cffec 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -6,8 +6,9 @@ (ns app.render-wasm.text-paste "Restyles the text WASM just inserted from a paste fragment (see - `app.util.text.clipboard`) with each run's overrides." + `app.util.text.clipboard`), with run overrides or full Penpot styles." (:require + [app.common.types.text :as txt] [app.main.fonts :as fonts] [app.render-wasm.text-editor :as text-editor] [cuerdas.core :as str])) @@ -90,3 +91,38 @@ (text-editor/apply-styles-over-range content range #(resolve-overrides % attrs))) content (styled-ranges fragment start))) + +;; --- Text copied in Penpot + +(def ^:private style-attrs + (into txt/paragraph-attrs txt/text-node-attrs)) + +(defn content->fragment + "The paste fragment for Penpot `content`. Its attrs are whole paragraph and span + styles, not overrides." + [content] + (->> (-> content :children first :children) + (mapv (fn [paragraph] + {:attrs (dissoc paragraph :type :children) + :children (into [] + (comp (filter (comp seq :text)) + (map (fn [span] {:text (:text span) :attrs (dissoc span :text)}))) + (:children paragraph))})))) + +(defn- restyle + "`node` with its style replaced by `attrs`." + [node attrs] + (merge (apply dissoc node style-attrs) attrs)) + +(defn apply-content-styles + "Restyles the Penpot `fragment` text WASM inserted at `start` in `content`. The + first paragraph keeps the style of the one it went into; the others bring their own." + [content fragment start] + (let [content (reduce (fn [content {:keys [range attrs]}] + (text-editor/apply-styles-over-range content range #(restyle % attrs))) + content + (styled-ranges fragment start))] + (reduce (fn [content [idx {:keys [attrs]}]] + (update-in content [:children 0 :children (+ (:para start) idx)] restyle attrs)) + content + (rest (map-indexed vector fragment))))) diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index 3199a6f093..1f0b7decda 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -24,16 +24,14 @@ :allowHTMLPaste false}) (defn plain-text->html - "Build a minimal text/html clipboard payload from plain text. - - Windows apps often prefer CF_HTML over CF_UNICODETEXT; writing only - text/plain from a contenteditable copy handler can leave them with the - editor surface's empty/`
` fallback (a lone newline)." - [text] - (let [escaped (-> (or text "") - (dom/escape-html) - (str/replace "\n" "
"))] - (str "" escaped))) + "Minimal text/html clipboard payload for `text`, with `hidden-html` before it. + Windows apps prefer CF_HTML, and may otherwise paste the editor's empty `
`." + ([text] (plain-text->html text nil)) + ([text hidden-html] + (let [escaped (-> (or text "") + (dom/escape-html) + (str/replace "\n" "
"))] + (str "" hidden-html escaped)))) (defn- from-data-transfer "Get clipboard stream from DataTransfer instance" @@ -225,6 +223,34 @@ (.readText ^js clipboard) (unavailable-error)))) +(defn- read-item-type + "Promise of the `mime` text of clipboard `item`, or of nil when it has none." + [^js item mime] + (if (some #{mime} (some-> item .-types array-seq)) + (-> (.getType item mime) (.then #(.text ^js %))) + (js/Promise.resolve nil))) + +(defn read-html-and-text + "Promise of the system clipboard as {:html :text}, either may be nil. Rejects like + `to-clipboard` when the asynchronous Clipboard API is not exposed." + [] + (let [clipboard (get-clipboard)] + (cond + (and clipboard (unchecked-get clipboard "read")) + (-> (.read ^js clipboard) + (.then (fn [items] + (let [item (first items)] + (-> (js/Promise.all #js [(read-item-type item "text/html") + (read-item-type item "text/plain")]) + (.then (fn [[html text]] {:html html :text text}))))))) + + (and clipboard (unchecked-get clipboard "readText")) + (-> (.readText ^js clipboard) + (.then (fn [text] {:html nil :text text}))) + + :else + (unavailable-error)))) + (defn permission-error? "True for the `NotAllowedError` DOMException raised when access is denied." [cause] diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index 4c806fd1c4..f0f76bee6b 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -6,8 +6,14 @@ (ns app.util.text.clipboard "Reads clipboard data into paste fragments: `[{:attrs {} :children [{:text :attrs}]}]`, - where run attrs are emphasis overrides laid over the style at the caret." + whose run attrs override the caret style; Penpot text also carries a hidden payload." (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.fonts :as cfnt] + [app.common.transit :as t] + [app.common.types.text :as txt] + [app.common.types.typography :as ctt] [cuerdas.core :as str])) (def ^:private block-tags @@ -263,3 +269,96 @@ {:attrs {} :children (if (empty? line) [] [{:text line :attrs {}}])}) (.split (str/replace text "\r" "") "\n")))) + +;; --- Penpot payload +;; +;; Text copied in Penpot keeps its content in the `data-penpot-text` attr of an +;; empty span in the HTML: the one hidden marker every browser keeps. + +(def ^:private payload-re #"data-penpot-text=\"([A-Za-z0-9+/=]*)\"") + +(defn- encode-base64 + [s] + (->> (.encode (js/TextEncoder.) s) + (.from js/Array) + (map #(.fromCharCode js/String %)) + (str/join) + (js/btoa))) + +(defn- decode-base64 + [s] + (->> (.from js/Uint8Array (js/atob s) #(.charCodeAt % 0)) + (.decode (js/TextDecoder.)))) + +(defn payload->html + "Hidden markup carrying `content`, copied from `file-id` in `team-id`." + [content file-id team-id] + (let [payload {:type :copied-text + :version 1 + :file-id file-id + :team-id team-id + :content content}] + (str ""))) + +(defn html->payload + "The Penpot payload in `html`, or nil when it has none." + [html] + (when-let [[_ encoded] (some->> html (re-find payload-re))] + (let [payload (ex/ignoring (t/decode-str (decode-base64 encoded)))] + (when (and (map? payload) + (= :copied-text (:type payload)) + (map? (:content payload))) + payload)))) + +;; --- Pasting into another file or team + +(def ^:private default-font + (d/seek #(= (:id %) (:font-id txt/default-text-attrs)) cfnt/local-fonts)) + +(defn- replace-custom-font + "`node` in the default font, at the closest weight and style it has." + [{:keys [font-weight font-style] :as node}] + (let [variant (or (cfnt/closest-variant (:variants default-font) (str font-weight) font-style) + (d/seek #(= (:id %) (:font-variant-id txt/default-text-attrs)) (:variants default-font)))] + (-> node + (assoc :font-id (:id default-font) + :font-family (:family default-font) + :font-variant-id (:id variant) + :font-weight (:weight variant) + :font-style (:style variant)) + (ctt/remove-typography-from-node)))) + +(defn replace-custom-fonts + "`content` with every custom font replaced by the default one, for pasting it + into another team, where custom fonts have other ids." + [content] + (txt/transform-nodes #(= :custom (cfnt/font-id->backend (:font-id %))) + replace-custom-font + content)) + +(defn- drop-image-fills + "`node` without image fills, or with the default fill when it had only those." + [{:keys [fills] :as node}] + (if (some :fill-image fills) + (let [fills (into [] (remove :fill-image) fills)] + (assoc node :fills (if (seq fills) fills (txt/get-default-text-fills)))) + node)) + +(defn clean-content + "`content` without what the paste target cannot reach: typographies outside + `valid-file-ids`, custom fonts from another team, images from another file." + [content {:keys [valid-file-ids same-team? same-file?]}] + (txt/transform-nodes + (fn [node] + (cond-> node + (and (some? (:typography-ref-file node)) + (not (contains? valid-file-ids (:typography-ref-file node)))) + (ctt/remove-typography-from-node) + + (and (not same-team?) + (= :custom (cfnt/font-id->backend (:font-id node)))) + (replace-custom-font) + + (not same-file?) + (drop-image-fills))) + content)) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs new file mode 100644 index 0000000000..675b1ea18a --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-wasm.text-editor-content-range-test + "The styled content of a char range, as copied from the editor selection." + (:require + [app.render-wasm.text-editor :as text-editor] + [cljs.test :as t :include-macros true])) + +(defn- span [text attrs] + (assoc attrs :text text)) + +(defn- paragraph [attrs & spans] + (assoc attrs :type "paragraph" :children (vec spans))) + +(defn- content [& paragraphs] + {:type "root" + :children [{:type "paragraph-set" :children (vec paragraphs)}]}) + +(def ^:private bold {:font-weight "700"}) +(def ^:private red {:fills [{:fill-color "#ff0000" :fill-opacity 1}]}) +(def ^:private centered {:text-align "center"}) +(def ^:private right {:text-align "right"}) + +(def ^:private source + (content (paragraph centered (span "Hello " {}) (span "bold" bold) (span " end" {})) + (paragraph right (span "red" red) (span " tail" {})))) + +(t/deftest single-paragraph + (t/testing "spans are cut at both ends and keep their styles" + (t/is (= (content (paragraph centered (span "lo " {}) (span "bold" bold) (span " e" {}))) + (text-editor/content-range source {:start-para 0 :start-offset 3 + :end-para 0 :end-offset 12}))))) + +(t/deftest across-paragraphs + (t/testing "every paragraph in the range keeps its attrs" + (t/is (= (content (paragraph centered (span "end" {})) + (paragraph right (span "re" red))) + (text-editor/content-range source {:start-para 0 :start-offset 11 + :end-para 1 :end-offset 2})))) + + (t/testing "a range starting at a paragraph end keeps an empty paragraph with its style" + (t/is (= (content (paragraph centered (span "" {})) + (paragraph right (span "red" red))) + (text-editor/content-range source {:start-para 0 :start-offset 14 + :end-para 1 :end-offset 3}))))) + +(t/deftest utf16-offsets + (t/testing "offsets count UTF-16 units like WASM's" + (t/is (= (content (paragraph {} (span "😀b" bold))) + (text-editor/content-range (content (paragraph {} (span "a😀b" bold))) + {:start-para 0 :start-offset 1 + :end-para 0 :end-offset 4}))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs index 7394d031a4..795309aff3 100644 --- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -144,3 +144,49 @@ (let [result (text-paste/fragment->content [(fragment-paragraph (run "x"))] regular)] (t/is (= (assoc regular :type "paragraph") (dissoc (-> result :children first :children first) :children)))))) + +;; --- Text copied in Penpot + +(def ^:private brand + {:font-id "gfont-roboto" :font-family "Roboto" :font-variant-id "700italic" + :font-weight "700" :font-style "italic" + :fills [{:fill-color "#ff0000" :fill-opacity 1}]}) + +(t/deftest content->fragment + (t/testing "paragraphs and spans keep their whole style, and empty spans are dropped" + (t/is (= [{:attrs {:text-align "center"} + :children [{:text "x" :attrs brand}]} + {:attrs {:text-align "right"} + :children []}] + (text-paste/content->fragment + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" :text-align "center" + :children [(assoc brand :text "x") (assoc brand :text "")]} + {:type "paragraph" :text-align "right" + :children [(assoc brand :text "")]}]}]}))))) + +(t/deftest penpot-styles-replace-the-caret-style + (t/testing "pasted runs take their whole style, dropping the target's typography" + (let [pasted (content [(span "AxyB" {:typography-ref-id "t" :typography-ref-file "f"})]) + fragment [(fragment-paragraph (run "x" brand) (run "y" regular))] + result (text-paste/apply-content-styles pasted fragment {:para 0 :offset 1})] + (t/is (= [[(span "A" {:typography-ref-id "t" :typography-ref-file "f"}) + (assoc brand :text "x") + (span "y") + (span "B" {:typography-ref-id "t" :typography-ref-file "f"})]] + (spans-of result))))) + + (t/testing "the first paragraph keeps its style; later ones bring theirs" + (let [pasted {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" :text-align "left" :children [(span "Ax")]} + {:type "paragraph" :text-align "left" :children [(span "yB")]}]}]} + fragment [{:attrs {:text-align "center"} :children [(run "x" brand)]} + {:attrs {:text-align "right"} :children [(run "y" brand)]}] + result (text-paste/apply-content-styles pasted fragment {:para 0 :offset 1})] + (t/is (= ["left" "right"] + (map :text-align (-> result :children first :children)))) + (t/is (= [[(span "A") (assoc brand :text "x")] + [(assoc brand :text "y") (span "B")]] + (spans-of result)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index df7409c045..28d2c2419f 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -89,6 +89,7 @@ [frontend-tests.render-wasm.serialization-test] [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] + [frontend-tests.render-wasm.text-editor-content-range-test] [frontend-tests.render-wasm.text-paste-test] [frontend-tests.render-wasm.webgl-test] [frontend-tests.router-test] @@ -239,6 +240,7 @@ 'frontend-tests.render-wasm.serialization-test 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test + 'frontend-tests.render-wasm.text-editor-content-range-test 'frontend-tests.render-wasm.text-paste-test 'frontend-tests.render-wasm.webgl-test 'frontend-tests.router-test diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index d2d757fbb0..c0bcdbd07f 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -9,6 +9,8 @@ survives; the documents come from jsdom since the runner has no DOM." (:require ["jsdom" :refer [JSDOM]] + [app.common.types.text :as txt] + [app.common.uuid :as uuid] [app.util.text.clipboard :as clipboard] [cljs.test :as t :include-macros true])) @@ -174,3 +176,94 @@ (t/testing "empty paragraphs stay empty" (t/is (= [(paragraph)] (clipboard/without-overrides [(paragraph)]))))) + +;; --- Penpot payload + +(def ^:private file-id (uuid/next)) +(def ^:private team-id (uuid/next)) + +(defn- text-content [& spans] + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" :text-align "center" :children (vec spans)}]}]}) + +(def ^:private gradient-span + {:text "Ñandú 😀 \"\"" + :font-id "custom-6e2c1a5e-1c1e-4b54-9c64-2a8c5f1a4c11" + :font-family "Brand Sans" + :font-variant-id "bold" + :font-weight "700" + :font-style "italic" + :fills [{:fill-color-gradient {:type :linear :start-x 0 :start-y 0 :end-x 1 :end-y 1 :width 1 + :stops [{:color "#ff0000" :opacity 1 :offset 0} + {:color "#0000ff" :opacity 1 :offset 1}]} + :fill-opacity 1}]}) + +(t/deftest payload-round-trip + (let [content (text-content gradient-span) + hidden (clipboard/payload->html content file-id team-id)] + (t/testing "the payload decodes back to the same content, file and team" + (t/is (= {:type :copied-text :version 1 :file-id file-id :team-id team-id :content content} + (clipboard/html->payload (str "" hidden "Hello"))))) + + (t/testing "it survives the markup browsers add around it" + (let [safari (-> hidden + (.replace ">" " style=\"color: rgb(0, 0, 0); font-size: medium;\">"))] + (t/is (= content + (:content (clipboard/html->payload + (str "" safari "Hello"))))))))) + +(t/deftest html-without-payload + (t/testing "HTML without a payload, or with a broken one, gives nil" + (t/is (nil? (clipboard/html->payload "

Hello

"))) + (t/is (nil? (clipboard/html->payload ""))) + (t/is (nil? (clipboard/html->payload nil))))) + +;; --- Clean-up + +(def ^:private image-fill + {:fill-image {:id (uuid/next) :width 10 :height 10 :mtype "image/png"} :fill-opacity 1}) + +(def ^:private red-fill + {:fill-color "#ff0000" :fill-opacity 1}) + +(defn- spans-of [content] + (-> content :children first :children first :children)) + +(t/deftest clean-content + (let [library-id (uuid/next) + typography {:typography-ref-file library-id :typography-ref-id (uuid/next)} + content (text-content (merge gradient-span typography) + {:text "img" :fills [image-fill red-fill]} + {:text "only img" :fills [image-fill]}) + same {:valid-file-ids #{library-id} :same-team? true :same-file? true}] + + (t/testing "in the same file nothing changes" + (t/is (= content (clipboard/clean-content content same)))) + + (t/testing "typographies from unreachable files are dropped" + (t/is (nil? (-> (clipboard/clean-content content (assoc same :valid-file-ids #{})) + (spans-of) + (first) + :typography-ref-id)))) + + (t/testing "custom fonts from another team become the default font, at the closest variant" + (let [span (first (spans-of (clipboard/clean-content content (assoc same :same-team? false))))] + (t/is (= {:font-id "sourcesanspro" :font-family "sourcesanspro" + :font-variant-id "bolditalic" :font-weight "700" :font-style "italic"} + (select-keys span [:font-id :font-family :font-variant-id :font-weight :font-style]))) + (t/is (nil? (:typography-ref-id span))))) + + (t/testing "builtin and google fonts stay in another team" + (let [content (text-content (assoc gradient-span :font-id "gfont-roboto" :font-family "Roboto"))] + (t/is (= content (clipboard/clean-content content (assoc same :same-team? false)))))) + + (t/testing "images from another file are dropped, leaving the default fill when nothing is left" + (t/is (= [(:fills gradient-span) [red-fill] (txt/get-default-text-fills)] + (map :fills (spans-of (clipboard/clean-content content (assoc same :same-file? false))))))))) + +(t/deftest replace-custom-fonts + (t/testing "only custom fonts become the default font" + (let [content (text-content gradient-span (assoc gradient-span :font-id "gfont-roboto" :font-family "Roboto"))] + (t/is (= ["sourcesanspro" "gfont-roboto"] + (map :font-id (spans-of (clipboard/replace-custom-fonts content)))))))) From 28c576e1e6560eaad3a7c8ba3ae5c47d83f6138c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Thu, 8 Oct 2026 11:38:13 +0200 Subject: [PATCH 068/325] :bug: Preserve admin console URL path segments (#12196) --- backend/src/app/nitrate.clj | 26 ++++++++++----------- backend/test/backend_tests/nitrate_test.clj | 11 ++++++++- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index d91772f0cd..f911307ea2 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -265,7 +265,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "members/" profile-id) schema:profile-organization params)) @@ -285,7 +285,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "summary") schema:organization-summary params)) @@ -294,7 +294,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/users/" - profile-id + (str profile-id "/") "owned-organizations") [:vector schema:organization-summary] params)) @@ -314,7 +314,7 @@ (let [organizations (request-to-nitrate cfg :get (generate-nitrate-uri "api/users/" - profile-id + (str profile-id "/") "owned-organizations-summary") [:vector schema:organization-summary-counts] params)] @@ -329,7 +329,7 @@ (request-to-nitrate cfg :post (generate-nitrate-uri "api/users/" - profile-id + (str profile-id "/") "cleanup-after-deletion") nil params)) @@ -340,7 +340,7 @@ team (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "add-team") cto/schema:team-with-organization params) custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])] @@ -357,7 +357,7 @@ (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "add-user") schema:profile-organization params))) @@ -372,7 +372,7 @@ (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "remove-user") nil params))) @@ -382,7 +382,7 @@ (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "remove-team") nil params))) @@ -439,7 +439,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "permissions") [:map [:organization-id ::sm/uuid] @@ -453,7 +453,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "sso") schema:nitrate-sso params)) @@ -461,7 +461,7 @@ (defn- get-organization-sso-by-team-api [cfg {:keys [team-id] :as params}] (request-to-nitrate cfg :get - (generate-nitrate-uri "api/teams/" team-id "sso") + (generate-nitrate-uri "api/teams/" (str team-id "/") "sso") schema:nitrate-sso params)) @@ -470,7 +470,7 @@ (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" - organization-id + (str organization-id "/") "members-list") [:vector ::sm/uuid] params)) diff --git a/backend/test/backend_tests/nitrate_test.clj b/backend/test/backend_tests/nitrate_test.clj index c754539a95..0b3086dfbe 100644 --- a/backend/test/backend_tests/nitrate_test.clj +++ b/backend/test/backend_tests/nitrate_test.clj @@ -29,6 +29,15 @@ "http://ac.example/admin-console/api/teams/team-id/users/profile-id"] ["nested organization membership path" ["api/organizations/" "organization-id/" "members/" "profile-id"] - "http://ac.example/admin-console/api/organizations/organization-id/members/profile-id"]]] + "http://ac.example/admin-console/api/organizations/organization-id/members/profile-id"] + ["organization action path" + ["api/organizations/" "organization-id/" "add-team"] + "http://ac.example/admin-console/api/organizations/organization-id/add-team"] + ["user action path" + ["api/users/" "profile-id/" "cleanup-after-deletion"] + "http://ac.example/admin-console/api/users/profile-id/cleanup-after-deletion"] + ["team action path" + ["api/teams/" "team-id/" "sso"] + "http://ac.example/admin-console/api/teams/team-id/sso"]]] (t/testing label (t/is (= expected (apply generate-nitrate-uri segments))))))) From 2c08a065bbbb12fa3036a7c9ae8526d43bec4f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 8 Oct 2026 16:50:00 +0200 Subject: [PATCH 069/325] :wrench: Auto-detect architecture to set the right skia binaries for test scripts (#12224) --- render-wasm/test | 9 +++++++-- render-wasm/watch_test | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/render-wasm/test b/render-wasm/test index c27d5ea533..360603261d 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -1,8 +1,13 @@ #!/usr/bin/env bash set -x -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp-pdf.tar.gz"} -export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"}; +case "$(uname -m)" in + aarch64|arm64) _ARCH="aarch64" ;; + *) _ARCH="x86_64" ;; +esac + +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-${_ARCH}-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp-pdf.tar.gz"} +export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"${_ARCH}-unknown-linux-gnu"}; _SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) pushd "$_SCRIPT_DIR" > /dev/null diff --git a/render-wasm/watch_test b/render-wasm/watch_test index 27106e49bd..3422891457 100755 --- a/render-wasm/watch_test +++ b/render-wasm/watch_test @@ -1,7 +1,13 @@ #!/usr/bin/env bash _SCRIPT_DIR=$(dirname $0); -export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp-pdf.tar.gz" +case "$(uname -m)" in + aarch64|arm64) _ARCH="aarch64" ;; + *) _ARCH="x86_64" ;; +esac + +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-${_ARCH}-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp-pdf.tar.gz"} +export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"${_ARCH}-unknown-linux-gnu"}; pushd $_SCRIPT_DIR; cargo watch -x "test --bin render_wasm -- --show-output" From d0351a3ee264cc918c783407f9e67d45f21eddfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Fri, 9 Oct 2026 08:50:35 +0200 Subject: [PATCH 070/325] :sparkles: Add event name for create attributes (#12165) * :sparkles: Add event name for create attributes * :paperclip: Code review --- backend/src/app/loggers/audit/archive_task.clj | 1 + .../test/backend_tests/loggers_audit_archive_task_test.clj | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index cf4e12e6b7..324e817138 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -82,6 +82,7 @@ "change-organization-advanced-permission" "create-file" "create-organization" + "create-organization-attribute" "create-organization-invitation" "create-project" "create-team" diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index 0172ec06d5..27c2c2f872 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -76,14 +76,16 @@ (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") + (insert-audit-row! (:id prof) "create-organization-attribute") (insert-audit-row! (:id prof) "unrelated-event") (th/run-task! :audit-log-archive {:uri archive-uri}) (t/is (:called? @nitrate-mock)) (t/is (:called? @http-mock)) (let [[_ _ params] (:call-args @nitrate-mock)] - (t/is (= ["create-project"] (mapv :name (:events params))))) + (t/is (= #{"create-project" "create-organization-attribute"} + (into #{} (map :name) (:events params))))) (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 2 (count rows)))))))) + (t/is (= 3 (count rows)))))))) (t/deftest archive-events-skips-nitrate-when-no-matching-names (let [prof (th/create-profile* 1 {:is-active true})] From 7745c1f052dc8721c563865d4db5dbeaa9aa569b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Fri, 9 Oct 2026 10:46:33 +0200 Subject: [PATCH 071/325] :sparkles: Make the whole current plan card clickable (#12238) --- frontend/src/app/main/ui/dashboard/subscription.cljs | 10 +++++----- frontend/src/app/main/ui/dashboard/subscription.scss | 11 +++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 5548a5159e..c93644d90c 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -252,12 +252,12 @@ subscription-type (if nitrate? (:type nitrate-license) (get-subscription-type subscription)) subscription-is-trial (= "trialing" (:status (if nitrate? nitrate-license subscription))) go-to-subscription (mf/use-fn #(st/emit! (rt/nav :settings-subscription)))] - [:div {:class (stl/css :nitrate-current-plan)} - [:div {:class (stl/css :nitrate-current-plan-label)} + [:button {:class (stl/css :nitrate-current-plan) + :type "button" + :on-click go-to-subscription} + [:span {:class (stl/css :nitrate-current-plan-label)} (tr "subscription.current-plan.title")] - [:button {:class (stl/css :nitrate-current-plan-text) - :type "button" - :on-click go-to-subscription} + [:span {:class (stl/css :nitrate-current-plan-text)} (case subscription-type "professional" (tr "subscription.current-plan.professional") "unlimited" (if subscription-is-trial diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index c4635b94f1..43a6d9419b 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -255,12 +255,19 @@ } .nitrate-current-plan { + display: flex; + flex-direction: column; + align-items: flex-start; + box-sizing: border-box; + inline-size: calc(100% - 2 * var(--sp-m)); border-radius: var(--sp-s); margin-block: var(--sp-s) var(--sp-m); margin-inline: var(--sp-m); background: var(--color-background-tertiary); border: $b-1 solid var(--color-background-quaternary); padding: var(--sp-m) var(--sp-l); + text-align: start; + cursor: pointer; } .nitrate-current-plan-label { @@ -273,9 +280,5 @@ .nitrate-current-plan-text { @include t.use-typography("body-medium"); - background: none; - border: none; - padding: 0; - cursor: pointer; color: var(--color-foreground-primary); } From 76e9c132efd0b66b57763c5ddd9dfcfda0051cdf Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 9 Oct 2026 10:58:05 +0200 Subject: [PATCH 072/325] :sparkles: Send more enriched events to nitrate log (#12210) * :sparkles: Send more enriched events to nitrate log * :sparkles: Fix mr --- .../src/app/loggers/audit/archive_task.clj | 92 +++++++++++- backend/src/app/rpc/commands/files.clj | 3 +- backend/src/app/rpc/commands/jobs.clj | 29 +++- backend/src/app/rpc/commands/teams.clj | 21 ++- .../loggers_audit_archive_task_test.clj | 141 ++++++++++++++++-- .../backend_tests/rpc/commands/jobs_test.clj | 37 ++++- backend/test/backend_tests/rpc_file_test.clj | 15 ++ backend/test/backend_tests/rpc_team_test.clj | 33 ++++ 8 files changed, 343 insertions(+), 28 deletions(-) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 324e817138..892c24895d 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -11,6 +11,7 @@ [app.common.schema :as sm] [app.common.transit :as t] [app.common.uri :as u] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http.client :as http] @@ -80,6 +81,7 @@ "add-team-to-organization" "cancel-organization-invitation" "change-organization-advanced-permission" + "create-export-binfile-job" "create-file" "create-organization" "create-organization-attribute" @@ -90,8 +92,10 @@ "create-team-invitation" "create-team-invitations" "create-webhook" + "delete-file" "delete-font" "delete-organization" + "delete-organization-member" "delete-project" "delete-team" "delete-team-invitation" @@ -108,6 +112,8 @@ "rename-organization" "rename-project" "restore-deleted-team-files" + "success-activate-organization-sso" + "success-apply-organization-sso-changes" "update-organization-invitation" "update-organization-permissions" "update-team-invitation" @@ -116,12 +122,96 @@ "update-team-photo" "verify-token"}) +;; Props id key -> email prop filled from profile.email (nitrate path only). +;; Some producers store ids as UUID strings (e.g. :user-who-send-invitation). +(def ^:private id->email-prop + {:profile-id :profile-email + :user-id :user-email + :member-id :member-email + :user-who-send-invitation :user-who-send-invitation-email}) + +(defn- collect-team-ids-needing-name + [events] + (into #{} + (comp (map :props) + (keep (fn [props] + (when-let [team-id (and (not (contains? props :team-name)) + (uuid/coerce (:team-id props)))] + team-id)))) + events)) + +(defn- collect-profile-ids-needing-email + [events] + (into #{} + (mapcat (fn [{:keys [props]}] + (keep (fn [[id-key email-key]] + (when-not (contains? props email-key) + (uuid/coerce (get props id-key)))) + id->email-prop))) + events)) + +(defn- load-team-names + [conn ids] + (if (seq ids) + (let [arr (db/create-array conn "uuid" ids) + rows (db/exec! conn ["SELECT id, name FROM team WHERE id = ANY(?)" arr])] + (into {} (map (juxt :id :name) rows))) + {})) + +(defn- load-profile-emails + [conn ids] + (if (seq ids) + (let [arr (db/create-array conn "uuid" ids) + rows (db/exec! conn ["SELECT id, email FROM profile WHERE id = ANY(?)" arr])] + (into {} (map (juxt :id :email) rows))) + {})) + +(defn- maybe-assoc-team-name + [event team-names] + (let [props (:props event)] + (if-let [team-name (and (not (contains? props :team-name)) + (when-let [team-id (uuid/coerce (:team-id props))] + (get team-names team-id)))] + (assoc-in event [:props :team-name] team-name) + event))) + +(defn- maybe-assoc-emails + [event emails] + (update event :props + (fn [props] + (reduce-kv (fn [props id-key email-key] + (if-let [email (and (not (contains? props email-key)) + (when-let [id (uuid/coerce (get props id-key))] + (get emails id)))] + (assoc props email-key email) + props)) + props + id->email-prop)))) + +(defn- enrich-events-for-nitrate + "Enrich allowlisted events before nitrate ingest: + 1. Fill missing :team-name from team id (including soft-deleted). + 2. Fill missing emails from matching id keys (including soft-deleted + profiles): :profile-email, :user-email, :member-email, + :user-who-send-invitation-email." + [{:keys [::db/conn]} events] + (let [team-ids (collect-team-ids-needing-name events) + profile-ids (collect-profile-ids-needing-email events) + team-names (load-team-names conn team-ids) + emails (load-profile-emails conn profile-ids)] + (mapv (fn [event] + (-> event + (maybe-assoc-team-name team-names) + (maybe-assoc-emails emails))) + events))) + (defn- send-to-nitrate! [cfg rows] (when-let [events (->> rows (filterv #(contains? event-names-for-nitrate (:name %))) (not-empty))] - (nitrate/call cfg :ingest-audit-log {:events events}))) + (nitrate/call cfg :ingest-audit-log + {:events (enrich-events-for-nitrate cfg events)}))) (defn- mark-archived! [{:keys [::db/conn]} rows] diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 63e116391b..372f97ab59 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1095,7 +1095,8 @@ :profile-id profile-id}) (rph/with-meta (rph/wrap) - {::audit/props {:project-id (:project-id file) + {::audit/props {:team-id (:id team) + :project-id (:project-id file) :name (:name file) :created-at (:created-at file) :modified-at (:modified-at file)}}))) diff --git a/backend/src/app/rpc/commands/jobs.clj b/backend/src/app/rpc/commands/jobs.clj index d1aa5b85cb..54c7c352ed 100644 --- a/backend/src/app/rpc/commands/jobs.clj +++ b/backend/src/app/rpc/commands/jobs.clj @@ -110,6 +110,25 @@ ;; EXPORT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- single-team-id + "The team id when every `file-ids` entry resolves and they share one + team. Otherwise nil." + [cfg file-ids] + (when (seq file-ids) + (db/run! cfg + (fn [{:keys [::db/conn]}] + (let [arr (db/create-array conn "uuid" file-ids) + rows (db/exec! conn + ["SELECT f.id AS file_id, t.id AS team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + JOIN team AS t ON (t.id = p.team_id) + WHERE f.id = ANY(?)" arr]) + teams (into #{} (map :team-id) rows)] + (when (and (= (count file-ids) (count rows)) + (= 1 (count teams))) + (first teams))))))) + (sv/defmethod ::create-export-binfile-job "Create a durable job that exports a set of files as a `.penpot` package. @@ -146,11 +165,13 @@ ::quotes/profile-id profile-id}) (let [summary (get-job-summary cfg (submit-job cfg job-def :export-binfile - params profile-id))] + params profile-id)) + team-id (single-team-id cfg file-ids)] (with-meta summary - {::audit/props {:job-id (:id summary) - :files (count file-ids) - :export-type (d/name (:export-type params))}})))) + {::audit/props (cond-> {:job-id (:id summary) + :files (count file-ids) + :export-type (d/name (:export-type params))} + team-id (assoc :team-id team-id))})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMPORT diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 8c31676d3d..ea8787ff67 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -29,6 +29,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] @@ -867,11 +868,13 @@ :code :non-deletable-team :hint "impossible to delete default team")) - (let [delay (ldel/get-deletion-delay team) - team (db/update! conn :team - {:deleted-at (ct/in-future delay)} - {:id team-id} - {::db/return-keys true})] + (let [delay (ldel/get-deletion-delay team) + organization-id (dm/get-in team [:organization :id]) + team-name (:name team) + team (db/update! conn :team + {:deleted-at (ct/in-future delay)} + {:id team-id} + {::db/return-keys true})] ;; Api call to nitrate (when (contains? cf/flags :admin-console) @@ -882,7 +885,10 @@ ::jobs/params {:object :team :deleted-at (:deleted-at team) :id team-id}}) - team))) + (rph/with-meta (rph/wrap) + {::audit/props {:organization-id organization-id + :team-id team-id + :team-name team-name}})))) (def ^:private schema:delete-team [:map {:title "delete-team"} @@ -894,8 +900,7 @@ ::sm/params schema:delete-team ::db/transaction true} [cfg {:keys [::rpc/profile-id id] :as params}] - (delete-team cfg {:team-id id :profile-id profile-id}) - nil) + (delete-team cfg {:team-id id :profile-id profile-id})) ;; --- Mutation: Team Update Role diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index 27c2c2f872..863885a085 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -26,18 +26,25 @@ (def ^:private archive-uri "http://nexus.example/archive") (defn- insert-audit-row! - [profile-id name] - (th/db-insert! :audit-log - {:id (uuid/next) - :name name - :type "action" - :source "backend" - :profile-id profile-id - :ip-addr (db/inet "127.0.0.1") - :props (db/tjson {:team-id (uuid/next)}) - :context (db/tjson {}) - :tracked-at (ct/now) - :created-at (ct/now)})) + ([profile-id name] + (insert-audit-row! profile-id name {:team-id (uuid/next)})) + ([profile-id name props] + (th/db-insert! :audit-log + {:id (uuid/next) + :name name + :type "action" + :source "backend" + :profile-id profile-id + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson props) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)}))) + +(defn- nitrate-event-props + [nitrate-mock] + (let [[_ _ params] (:call-args @nitrate-mock)] + (:props (first (:events params))))) (t/deftest archive-events-sends-to-nitrate-then-nexus ;; Create profile before enabling :admin-console — system nitrate @@ -231,3 +238,113 @@ (catch Throwable cause cause)))] (t/is (= :task-not-configured (:code data)))))) + +(t/deftest archive-events-enriches-team-name-and-emails + (let [actor (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" + {:team-id (:id team) + :profile-id (:id actor) + :user-id (:id member) + :member-id (:id member) + ;; Producers store this as a string UUID. + :user-who-send-invitation (str (:id actor))}) + (th/run-task! :audit-log-archive {}) + (t/is (:called? @nitrate-mock)) + (let [props (nitrate-event-props nitrate-mock)] + (t/is (= (:name team) (:team-name props))) + (t/is (= (:email actor) (:profile-email props))) + (t/is (= (:email member) (:user-email props))) + (t/is (= (:email member) (:member-email props))) + (t/is (= (:email actor) (:user-who-send-invitation-email props)))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-enriches-soft-deleted-team-and-profile + (let [actor (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (th/db-update! :team {:deleted-at (ct/now)} {:id (:id team)}) + (th/db-update! :profile {:deleted-at (ct/now)} {:id (:id other)}) + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" + {:team-id (:id team) + :profile-id (:id other)}) + (th/run-task! :audit-log-archive {}) + (let [props (nitrate-event-props nitrate-mock)] + (t/is (= (:name team) (:team-name props))) + (t/is (= (:email other) (:profile-email props)))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-does-not-overwrite-existing-enrich-props + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" + {:team-id (:id team) + :team-name "already-set-name" + :profile-id (:id actor) + :profile-email "already-set@example.com" + :user-id (:id actor) + :user-email "already-user@example.com" + :member-id (:id actor) + :member-email "already-member@example.com" + :user-who-send-invitation (str (:id actor)) + :user-who-send-invitation-email "already-inviter@example.com"}) + (th/run-task! :audit-log-archive {}) + (let [props (nitrate-event-props nitrate-mock)] + (t/is (= "already-set-name" (:team-name props))) + (t/is (= "already-set@example.com" (:profile-email props))) + (t/is (= "already-user@example.com" (:user-email props))) + (t/is (= "already-member@example.com" (:member-email props))) + (t/is (= "already-inviter@example.com" + (:user-who-send-invitation-email props)))))))) + +(t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids + (let [actor (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" + {:team-id (uuid/next) + :profile-id (uuid/next) + :user-id "not-a-uuid" + :member-id 42}) + (th/run-task! :audit-log-archive {}) + (t/is (:called? @nitrate-mock)) + (let [props (nitrate-event-props nitrate-mock)] + (t/is (nil? (:team-name props))) + (t/is (nil? (:profile-email props))) + (t/is (nil? (:user-email props))) + (t/is (nil? (:member-email props))) + (t/is (= "not-a-uuid" (:user-id props))) + (t/is (= 42 (:member-id props)))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 1 (count rows)))))))) + +(t/deftest archive-events-forwards-export-job-not-legacy-export + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-export-binfile-job" + {:team-id (:id team) + :job-id (uuid/next) + :files 1 + :export-type "detach-libraries"}) + (insert-audit-row! (:id actor) "export-binfile" + {:file-id (uuid/next)}) + (th/run-task! :audit-log-archive {}) + (let [[_ _ params] (:call-args @nitrate-mock) + events (:events params) + props (:props (first events))] + (t/is (= ["create-export-binfile-job"] (mapv :name events))) + (t/is (= (:id team) (:team-id props))) + (t/is (= (:name team) (:team-name props)))) + (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] + (t/is (= 2 (count rows)))))))) diff --git a/backend/test/backend_tests/rpc/commands/jobs_test.clj b/backend/test/backend_tests/rpc/commands/jobs_test.clj index a03b5a9ca4..a39f92d71e 100644 --- a/backend/test/backend_tests/rpc/commands/jobs_test.clj +++ b/backend/test/backend_tests/rpc/commands/jobs_test.clj @@ -173,11 +173,44 @@ out (create-export-binfile-job (:id profile) #{file-id}) props (::audit/props (meta (:result out)))] - (t/testing "the audit of the call points at the job, not at its files" + (t/testing "the audit of the call points at the job and its team" (t/is (= (:id (:result out)) (:job-id props))) (t/is (= 1 (:files props))) (t/is (= "detach-libraries" (:export-type props))) - (t/is (= #{:job-id :files :export-type} (set (keys props))))))) + (t/is (= (:default-team-id profile) (:team-id props))) + (t/is (= #{:job-id :files :export-type :team-id} (set (keys props)))) + (t/is (not (contains? (:result out) :team-id)))))) + +(t/deftest create-export-binfile-job-records-the-team-when-files-share-one + (let [profile (th/create-profile* 1) + file-a (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + file-b (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + out (create-export-binfile-job (:id profile) + #{(:id file-a) (:id file-b)}) + props (::audit/props (meta (:result out)))] + (t/is (th/success? out)) + (t/is (= (:default-team-id profile) (:team-id props))) + (t/is (= 2 (:files props))) + (t/is (not (contains? (:result out) :team-id))))) + +(t/deftest create-export-binfile-job-omits-the-team-when-files-span-teams + (let [profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file-a (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + file-b (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id project)}) + out (create-export-binfile-job (:id profile) + #{(:id file-a) (:id file-b)}) + props (::audit/props (meta (:result out)))] + (t/is (th/success? out)) + (t/is (nil? (:team-id props))) + (t/is (= #{:job-id :files :export-type} (set (keys props)))) + (t/is (not (contains? (:result out) :team-id))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMPORT diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 3104e30139..d976ed92de 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -17,6 +17,7 @@ [app.db.sql :as sql] [app.features.fdata :as fdata] [app.http :as http] + [app.loggers.audit :as-alias audit] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.storage :as sto] @@ -811,6 +812,20 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found)))) +(t/deftest delete-file-audit-includes-team-id + ;; Assert result metadata directly: wrap-audit is inactive in the + ;; default test flags, so mocking audit/submit would never see this. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + result (db/tx-run! th/*system* + (fn [cfg] + (#'files/delete-file cfg {:id (:id file) + :profile-id (:id profile)}))) + props (::audit/props (meta result))] + (t/is (= (:default-team-id profile) (:team-id props))) + (t/is (= (:project-id file) (:project-id props))))) + (t/deftest permissions-checks-set-file-shared (let [profile1 (th/create-profile* 1) profile2 (th/create-profile* 2) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index f066f53db6..7781605860 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -13,6 +13,7 @@ [app.db :as db] [app.email.blacklist :as email.blacklist] [app.http :as http] + [app.loggers.audit :as-alias audit] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.teams :as teams] @@ -928,6 +929,38 @@ (let [result (th/run-task! :objects-gc {})] (t/is (= 7 (:processed result))))))) +(t/deftest delete-team-audit-includes-organization-id + ;; Assert result metadata directly: wrap-audit is inactive in the + ;; default test flags, so mocking audit/submit would never see this. + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + organization-id (uuid/next) + organization {:id organization-id + :name "Acme" + :slug "acme" + :owner-id (:id owner) + :avatar-bg-url "https://example.com/avatar.svg" + :permissions {:delete-teams "onlyOwners"}}] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call + (fn [_cfg method params] + (case method + :get-team-organization + (when (= (:id team) (:team-id params)) + {:id (:id team) + :is-your-penpot false + :organization organization}) + :delete-team nil + nil))] + (let [result (db/tx-run! th/*system* + (fn [cfg] + (teams/delete-team cfg {:team-id (:id team) + :profile-id (:id owner)}))) + props (::audit/props (meta result))] + (t/is (= organization-id (:organization-id props))) + (t/is (= (:id team) (:team-id props))) + (t/is (= (:name team) (:team-name props))))))) + (t/deftest create-team-access-request (with-mocks [mock {:target 'app.email/send :return nil}] (let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"}) From 5afdeffa5f3cdc5218e91bc1c8fc9a4201cb2bda Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Fri, 9 Oct 2026 11:22:19 +0200 Subject: [PATCH 073/325] :recycle: Move frame type constants to webgl.cljs (#11949) * :recycle: Move some constants out of app.render-wasm.api - Moved to aapp.render-wasm.wasm where they can be imported without pulling half the editor with them - Renamed one for consistency * :fire: Delete unused function, update docstring --- common/src/app/common/render_wasm/wasm.cljs | 9 ++++- frontend/src/app/render_wasm/api.cljs | 39 ++++++--------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/common/src/app/common/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs index 81b21ca5d7..5726534a3a 100644 --- a/common/src/app/common/render_wasm/wasm.cljs +++ b/common/src/app/common/render_wasm/wasm.cljs @@ -6,8 +6,15 @@ (ns app.common.render-wasm.wasm) +;; Types of frames in the rendering pipeline +(def ^:const FRAME_TYPE_NONE 0) ;; This type should never leak +(def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more _render calls to end +(def ^:const FRAME_TYPE_FULL 2) ;; A frame is done +(def ^:const FRAME_TYPE_VIEWPORT_READY 3) ;; Viewport presented; interest tiles may still be pending +(def ^:const RENDER_FLAG_SYNC_TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause) + (defonce internal-frame-id nil) -(defonce internal-frame-type 0) +(defonce internal-frame-type FRAME_TYPE_NONE) (defonce internal-module #js {}) ;; Reference to the HTML canvas element. diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index d85571e1f0..f9852a1ae6 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -344,20 +344,11 @@ (declare request-render-preserving-target) (declare render-pending?) -;; These are the type of frames we have in our -;; render pipeline. -(def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak". -(def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end. -(def ^:const FRAME_TYPE_FULL 2) ;; A frame was full. -(def ^:const FRAME_TYPE_VIEWPORT_READY 3) ;; Viewport presented; interest tiles may still be pending. - (defn- needs-more-render-frames? "True when WASM still has progressive tile work (visible or interest ring)." [] - (or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) - (= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY))) - -(def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). + (or (= wasm/internal-frame-type wasm/FRAME_TYPE_PARTIAL) + (= wasm/internal-frame-type wasm/FRAME_TYPE_VIEWPORT_READY))) (defn- internal-render ([] @@ -455,7 +446,7 @@ ;; one-shot on both sides: WASM clears it when the render loop starts, so ;; the progressive continuation frames behave normally. (if (compare-and-set! preserve-target-render? true false) - (internal-render timestamp (bit-or wasm/internal-frame-type RENDER-FLAG-SYNC-TILES)) + (internal-render timestamp (bit-or wasm/internal-frame-type wasm/RENDER_FLAG_SYNC_TILES)) (internal-render timestamp)) ;; Update text editor blink (so cursor toggles) using the same timestamp @@ -660,7 +651,7 @@ (timers/cancel-af! frame-id) (set! wasm/internal-frame-id nil)) (reset! pending-render false) - (set! wasm/internal-frame-type FRAME_TYPE_NONE)) + (set! wasm/internal-frame-type wasm/FRAME_TYPE_NONE)) (defn- register-deferred-render! [] @@ -1483,7 +1474,7 @@ ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered ;; ends (e.g. selecting a shape opens the options panel and resizes the ;; viewport), which previously blanked. - (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES))) + (internal-render (js/performance.now) wasm/RENDER_FLAG_SYNC_TILES))) (def render-finish (letfn [(do-render [] @@ -1492,7 +1483,7 @@ (when (initialized?) (if (view-gesture-active?) ;; Pan/zoom pause: render without ending the interaction. - (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) + (internal-render (js/performance.now) wasm/RENDER_FLAG_SYNC_TILES) (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) @@ -1761,21 +1752,11 @@ ;; No pending images — complete immediately. (when on-complete (on-complete))))) -(defn process-object - [shape] - (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} - (set-object shape) - text-font-state (acc-text-font-state empty-text-font-state - (:id shape) - font-face-keys - pending-font-face-keys)] - (process-pending [shape] thumbnails full text-font-state noop-fn))) - (defn process-objects - "Like process-object but for multiple shapes at once. Accumulates all - pending font/image callbacks before calling process-pending, so that - update-text-layouts fires for all text shapes after fonts load — not - just the first shape that triggered the fetch." + "Takes multiple shapes, calling `set-object` for each one and accumulating + their thumbnails, full-image callbacks, and text font state. Then calls + `process-pending` once for the whole batch, so text layouts are updated for + all shapes after pending fonts and images are processed." [shapes] (let [total-shapes (count shapes) {:keys [thumbnails full text-font-state]} From cda19e0d5191bec6224634b08b384728cbfa123e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 9 Oct 2026 11:53:28 +0200 Subject: [PATCH 074/325] :arrow_up: Use Wasm EH and SjLj for render-wasm with Rust 1.99 (#12235) Rust 1.91 links emscripten with JS exception handling, which cannot mix with SUPPORT_LONGJMP=wasm. Bump the devenv to Rust 1.99 (native Wasm EH) and align EMCC_CFLAGS / SKIA_BINARIES_URL with a Skia -ljmp-wasm prebuild so +simd128 no longer hits invoke_*V TypeErrors. --- render-wasm/_build_env | 28 ++++++++++++++++++---------- render-wasm/lint | 3 --- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 87083df47f..11c192fbb2 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -1,7 +1,7 @@ #!/usr/bin/env bash -export RUST_VERSION=1.91.0 +export RUST_VERSION=1.99.0 export VERSION_TAG=${VERSION:-develop}; export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}"; @@ -22,11 +22,13 @@ fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} +# Skia prebuild with wasm SIMD and Wasm EH/SjLj (-ljmp-wasm). Must match +# EMCC_CFLAGS below. Override with SKIA_BINARIES_URL when needed. +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd-ljmp-wasm.tar.gz"} -# Let rustc emit wasm SIMD for our Rust code (autovec / core::arch::wasm32). -# Skia SIMD still comes from the -simd prebuild + -msimd128 in EMCC_CFLAGS. -# Gate on the emscripten target so native `./test` is unaffected. +# Emscripten only: rustc wasm SIMD (native `./test` unchanged). Wasm +# setjmp/longjmp comes from EMCC_CFLAGS (-sSUPPORT_LONGJMP=wasm), which emcc +# appends on compile and link. if [ "$CARGO_BUILD_TARGET" = "wasm32-unknown-emscripten" ]; then export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+simd128" fi @@ -48,12 +50,14 @@ export EM_MEMORY_GROWTH_GEOMETRIC_STEP="0.8" # Default: dlmalloc export EM_MALLOC="dlmalloc" -# -msimd128: allow wasm SIMD in the linked module. Skia prebuilts must be -# built with the same flag (penpot/skia-binaries, WASM_SIMD=1) for Skia/skcms -# kernels to actually contain v128 ops; engines do not autovec scalar wasm. +# -msimd128: wasm SIMD in the linked module (Skia -simd prebuild must match). +# -fwasm-exceptions / SUPPORT_LONGJMP=wasm: native Wasm EH and setjmp/longjmp +# (Skia -ljmp-wasm prebuild must match). export EMCC_CFLAGS="--no-entry \ --js-library src/js/wapi.js \ -msimd128 \ + -fwasm-exceptions \ + -sSUPPORT_LONGJMP=wasm \ -sMALLOC=$EM_MALLOC \ -sINVOKE_RUN=0 \ -sALLOW_TABLE_GROWTH=0 \ @@ -66,7 +70,6 @@ export EMCC_CFLAGS="--no-entry \ -sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \ -sENVIRONMENT=web,worker,node \ -sMODULARIZE=1 \ - -sDISABLE_EXCEPTION_CATCHING=1 \ -sFILESYSTEM=0 \ -sEXPORT_ES6=1"; @@ -114,10 +117,15 @@ function clean { cargo clean; } -function setup { +function setup_rust { # Setup default rust version rustup default $RUST_VERSION rustup target add wasm32-unknown-emscripten + rustup component add clippy rustfmt --toolchain $RUST_VERSION +} + +function setup { + setup_rust pnpm install; } diff --git a/render-wasm/lint b/render-wasm/lint index aeacf782c4..097a4314cf 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -10,9 +10,6 @@ fi . ./_build_env frontend -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd.tar.gz"} - - ALLOWED_RULES="-D static_mut_refs" # ./lint --fix From 6839b16735731fc45eae6d9f68fab09e22595d61 Mon Sep 17 00:00:00 2001 From: Francis Santiago Date: Fri, 9 Oct 2026 12:26:57 +0200 Subject: [PATCH 075/325] :books: Document subpath configuration and migration --- docs/technical-guide/configuration.md | 43 +++++++++++++++++ .../technical-guide/getting-started/docker.md | 46 +++++++++++++++++++ .../getting-started/kubernetes.md | 16 +++++++ 3 files changed, 105 insertions(+) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 95412109c9..f65127f995 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -372,6 +372,49 @@ PENPOT_PUBLIC_URI: https://penpot.mycompany.com If you're using the official docker-compose.yml you only need to configure the PENPOT_PUBLIC_URI envvar in the top of the file. +### Serve Penpot from a subpath + +Penpot can be served from a subpath such as `https://mycompany.com/penpot/`. +Set `PENPOT_PUBLIC_URI` to the complete public URL, including the subpath and its +trailing slash: + +```bash +PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ +``` + +Your reverse proxy or Ingress must also: + +- Route requests under `/penpot/` to the Penpot frontend. +- Remove the `/penpot` prefix before forwarding each request. The frontend + continues to serve its internal routes from `/`. +- Preserve WebSocket upgrade headers for notifications and other services that + use WebSockets. + +Changing only `PENPOT_PUBLIC_URI` is not enough. Without the matching proxy +rewrite, static assets, API calls, and WebSocket connections will use routes that +the frontend does not recognize. + +#### Migrate an existing installation to a subpath + +Moving an installation from `/` to a subpath does not require a database or +asset migration. Users, teams, projects, files, and uploaded assets remain in +the existing storage. + +Before changing the public URL: + +1. Back up the database, assets, and current deployment configuration. +2. Configure the proxy or Ingress route and prefix rewrite. +3. Update `PENPOT_PUBLIC_URI` for every Penpot component that uses it. +4. Keep the existing `PENPOT_SECRET_KEY`, database, and asset storage + configuration. +5. Restart or redeploy Penpot and verify login, static assets, API requests, + WebSockets, file editing, and image uploads through the new URL. + +Existing bookmarks and integrations that use the previous root URL must be +updated. You may also configure a redirect from the previous URL. If the scheme +or hostname changes in addition to the path, review your authentication provider +callback URLs and cookie settings. Active users may need to sign in again. +

If you plan to serve Penpot under different domain than `localhost` without HTTPS, you need to disable the `secure` flag on cookies, with the `disable-secure-session-cookies` flag. diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 4d192aee11..3e8f9740a8 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -317,6 +317,52 @@ server { For full documentation, go to the [official website][2] +#### Serve Penpot from a subpath with NGINX + +First, set the complete public URL in `docker-compose.yaml`: + +```yaml +PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ +``` + +Then route the subpath to the Penpot frontend and remove the prefix before +forwarding the request: + +```nginx +server { + listen 443 ssl; + server_name mycompany.com; + + client_max_body_size 367001600; + + # Configure TLS and logs following your organization's practices. + ssl_certificate /path/to/fullchain; + ssl_certificate_key /path/to/privkey; + + location = /penpot { + return 301 /penpot/; + } + + location /penpot/ { + rewrite ^/penpot/(.*)$ /$1 break; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_redirect off; + proxy_pass http://localhost:9001; + } +} +``` + +This single location also covers the API and WebSocket endpoints because NGINX +forwards them after removing `/penpot`. Keep the trailing slash in +`PENPOT_PUBLIC_URI`. + ### Example with CADDY SERVER ```bash diff --git a/docs/technical-guide/getting-started/kubernetes.md b/docs/technical-guide/getting-started/kubernetes.md index 1582d4015b..fa0f5f8f45 100644 --- a/docs/technical-guide/getting-started/kubernetes.md +++ b/docs/technical-guide/getting-started/kubernetes.md @@ -96,6 +96,22 @@ file itself, which you can use as a basis for creating your own settings. You can also consult the list of parameters on the ArtifactHub page of the project. +### Serve Penpot from a subpath + +The chart serves Penpot from `/` by default and can also expose it from a +subpath. The required values depend on whether the cluster uses an Ingress +controller or Gateway API. + +See +Serving Penpot from a subpath +in the Helm chart documentation for the current configuration examples. + +When moving an existing release to a subpath, keep the existing database, +assets, and secret key settings. No data migration is required. Follow the +migration considerations in the [Penpot URI](/technical-guide/configuration/#penpot-uri) +section and verify the +installation through its new public URL after running `helm upgrade`. + ### Using OpenShift? If you are deploying Penpot on OpenShift, we recommend following the specific guidelines provided in our Penpot-helm documentation: From d7ccc0f3a69680fa7d5bb9082e4b5edfa0936085 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 9 Oct 2026 18:15:07 +0200 Subject: [PATCH 076/325] :sparkles: Add get-file-plugin-data RPC to read file plugin data (#10171) The Plugin API can write arbitrary key/value plugin data onto the file root, but it could only be read back by downloading and decoding the whole file. Add a `get-file-plugin-data` read command that returns the file-level plugin data map, optionally narrowed to a single namespace, so external tooling can fetch metadata such as issue or commit references cheaply. Read-only: reuses `bfc/get-file` and `check-read-permissions!`, and adds no data-model or change-type changes. Object/page level reads are intentionally left out of this first command. Relates to #10169 Signed-off-by: Filip Sajdak Co-authored-by: Claude Opus 4.8 Co-authored-by: Andrey Antukh --- backend/src/app/rpc/commands/files.clj | 26 ++++++++ backend/test/backend_tests/rpc_file_test.clj | 62 ++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 372f97ab59..67153861a3 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -22,6 +22,7 @@ [app.common.transit :as t] [app.common.types.components-list :as ctkl] [app.common.types.file :as ctf] + [app.common.types.plugins :as ctpg] [app.common.types.tokens-lib :as ctob] [app.common.uri :as uri] [app.config :as cf] @@ -721,6 +722,31 @@ (get-file-stats cfg id)) +;; --- COMMAND QUERY: get-file-plugin-data + +(def ^:private schema:get-file-plugin-data + [:map {:title "get-file-plugin-data"} + [:id ::sm/uuid] + [:namespace {:optional true} :keyword]]) + +(sv/defmethod ::get-file-plugin-data + "Return the file-level plugin data: the arbitrary key/value metadata + stored on the file root through the Plugin API. Optionally narrowed + to a single `namespace`. Lets external tooling read metadata such as + issue or commit references without downloading the whole file." + {::doc/added "2.20" + ::sm/params schema:get-file-plugin-data + ::sm/result ctpg/schema:plugin-data + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id namespace]}] + (check-read-permissions! conn profile-id id) + (let [plugin-data (-> (bfc/get-file cfg id) + (get-in [:data :plugin-data]))] + (if namespace + (select-keys plugin-data [namespace]) + (or plugin-data {})))) + + ;; --- COMMAND QUERY: get-file-libraries (def ^:private schema:get-file-libraries diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index d976ed92de..8feb0516fb 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2867,3 +2867,65 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) +(t/deftest get-file-plugin-data + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; A file without plugin data returns an empty map + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= {} (:result out)))) + + ;; Set file-level plugin data through a change + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :set-plugin-data + :object-type :file + :namespace :shared/traceability + :key "req-id" + :value "REQ-123"}]) + + ;; The full plugin-data map is returned + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= {:shared/traceability {"req-id" "REQ-123"}} (:result out)))) + + ;; A namespace filter narrows the result + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file) + :namespace :shared/traceability})] + (t/is (nil? (:error out))) + (t/is (= {:shared/traceability {"req-id" "REQ-123"}} (:result out)))) + + ;; A filter on an absent namespace returns an empty map + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file) + :namespace :plugin/absent})] + (t/is (nil? (:error out))) + (t/is (= {} (:result out)))))) + +(t/deftest get-file-plugin-data-forbidden + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false}) + out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id other) + :id (:id file)})] + + (t/is (not (nil? (:error out)))) + (let [edata (-> out :error ex-data)] + (t/is (= :not-found (:type edata)))))) From 1125f72d81902f37a0950cd1ce9f869a3cd87e41 Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:38:05 -0700 Subject: [PATCH 077/325] :bug: Remove third-party Work Sans import from plugin styles (#12087) @penpot/plugin-styles loaded Work Sans from fonts.bunny.net via a remote @import in core/fonts.css, which every plugin built with the package fetched at runtime. That broke air-gapped deployments, leaked user IPs to a third party, and forced deployments to widen the CSP style-src and font-src directives introduced in #11374. Drop the remote import and use the system-ui stack instead of 'Work Sans' in the same package (both in core/fonts.css for code blocks and in core/generic.css for the base typography, which relied on the same webfont), so plugins no longer reach any external domain and 'self' is enough for both CSP directives. Verified by bundling the package entry point with esbuild using the same options as tools/scripts/build-css.mjs: the produced styles.css is balanced, contains system-ui, and contains no external import. Closes #12023 Signed-off-by: ump45nose <1729065730@qq.com> --- plugins/libs/plugins-styles/src/lib/core/fonts.css | 4 +--- plugins/libs/plugins-styles/src/lib/core/generic.css | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/libs/plugins-styles/src/lib/core/fonts.css b/plugins/libs/plugins-styles/src/lib/core/fonts.css index 496ce3de84..9c6b3b91f0 100644 --- a/plugins/libs/plugins-styles/src/lib/core/fonts.css +++ b/plugins/libs/plugins-styles/src/lib/core/fonts.css @@ -1,5 +1,3 @@ -@import url('https://fonts.bunny.net/css?family=work-sans:400,500&display=swap'); - :root { /* Font weight */ --font-weight-regular: 400; @@ -14,7 +12,7 @@ } code { - font-family: 'Work Sans', sans-serif; + font-family: system-ui, sans-serif; } .display { diff --git a/plugins/libs/plugins-styles/src/lib/core/generic.css b/plugins/libs/plugins-styles/src/lib/core/generic.css index 1aab0a7f50..3851ea17ee 100644 --- a/plugins/libs/plugins-styles/src/lib/core/generic.css +++ b/plugins/libs/plugins-styles/src/lib/core/generic.css @@ -1,6 +1,6 @@ html, body { - font-family: 'Work Sans', sans-serif; + font-family: system-ui, sans-serif; font-optical-sizing: auto; font-style: normal; margin: 0; From fa5e5b61768233c9376f34aa2692f76efa8550c0 Mon Sep 17 00:00:00 2001 From: breken Date: Fri, 9 Oct 2026 10:39:40 -0700 Subject: [PATCH 078/325] :bug: Log errors readably in MCP execute_code console output (#11930) * :bug: Log errors readably in MCP execute_code console output The console captured during execute_code rendered every object argument with JSON.stringify. An Error has no enumerable fields, so logging a caught error printed "{}" and the agent lost the message. A circular object or a BigInt made JSON.stringify throw, so a console.log call aborted the executed code with an unrelated serialization error. Format console arguments in a small pure module: errors by their string form (name and message), other objects as indented JSON, falling back to the string form when JSON cannot serialize the value. AI-assisted-by: claude-opus-5.5 Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com> * :recycle: Keep console formatting within ExecuteCodeTaskConsole --------- Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com> Co-authored-by: breken-ai <312387581+breken-ai@users.noreply.github.com> --- .../plugin/src/ExecuteCodeTaskConsole.test.ts | 23 +++++++++++++++++ .../task-handlers/ExecuteCodeTaskHandler.ts | 25 ++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts diff --git a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts new file mode 100644 index 0000000000..40295aee4a --- /dev/null +++ b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ExecuteCodeTaskConsole } from "./task-handlers/ExecuteCodeTaskHandler.ts"; + +test("formats plain objects and primitives", () => { + const console = new ExecuteCodeTaskConsole(); + console.log({ a: 1 }, null, "text", 42, undefined); + assert.equal(console.getLog(), '[LOG] {\n "a": 1\n} null text 42 undefined\n'); +}); + +test("keeps the name and message of a logged error", () => { + const console = new ExecuteCodeTaskConsole(); + console.error("failed", new TypeError("shape is undefined")); + assert.equal(console.getLog(), "[ERROR] failed TypeError: shape is undefined\n"); +}); + +test("does not throw for values JSON cannot serialize", () => { + const console = new ExecuteCodeTaskConsole(); + const circular: { self?: unknown } = {}; + circular.self = circular; + console.log(circular, { big: 1n }); + assert.equal(console.getLog(), "[LOG] [object Object] [object Object]\n"); +}); diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 47004e5cfe..b2fff14f05 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -8,7 +8,7 @@ import { PenpotUtils } from "../PenpotUtils.ts"; * Provides the same interface as the native console object but appends * all output to an internal log string that can be retrieved. */ -class ExecuteCodeTaskConsole { +export class ExecuteCodeTaskConsole { /** * Accumulated log output from all console method calls. */ @@ -30,15 +30,32 @@ class ExecuteCodeTaskConsole { return this.logOutput; } + /** + * Formats one argument of a captured console call. + * Errors have no enumerable fields, and values such as circular objects + * cannot be serialized to JSON, so fall back to their string form. + */ + private formatLogArgument(arg: unknown): string { + if (arg instanceof Error) { + return String(arg); + } + if (typeof arg === "object") { + try { + return JSON.stringify(arg, null, 2); + } catch { + return String(arg); + } + } + return String(arg); + } + /** * Appends a formatted message to the log output. * @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR") * @param args - Arguments to log, will be stringified and joined */ private appendToLog(level: string, ...args: any[]): void { - const message = args - .map((arg) => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg))) - .join(" "); + const message = args.map((arg) => this.formatLogArgument(arg)).join(" "); this.logOutput += `[${level}] ${message}\n`; } From 10955f17e27943429a10e90360d0404e3f56e27c Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 9 Oct 2026 20:08:14 +0200 Subject: [PATCH 079/325] :sparkles: Add get-file-tokens RPC to read a file's design tokens (#12172) Clients that only need a file's design tokens, such as a CI job that syncs them to a git repository after an update-file webhook, had to download the whole file with get-file. get-file-tokens returns the file's tokens as a DTCG document, the same format the token export produces, or nil when the file has none. Active themes and sets come from the file's tokens status, so they reflect what is currently active. The document is returned as a plain map. A bare TokensLib is not a collection, so the response middleware would hand it to ring unencoded. Relates to #7091 Signed-off-by: Filip Sajdak Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- backend/src/app/rpc/commands/files.clj | 22 +++++++ backend/test/backend_tests/rpc_file_test.clj | 65 ++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 67153861a3..4751284992 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -746,6 +746,28 @@ (select-keys plugin-data [namespace]) (or plugin-data {})))) +;; --- COMMAND QUERY: get-file-tokens + +(def ^:private schema:get-file-tokens + [:map {:title "get-file-tokens"} + [:file-id ::sm/uuid]]) + +(sv/defmethod ::get-file-tokens + "Return the design tokens of a file as a DTCG document, or nil when + the file has none. Active themes and sets come from the file's + tokens status. Cheap alternative to `get-file` when only the tokens + are needed." + {::doc/added "2.20" + ::sm/params schema:get-file-tokens + ::sm/result [:maybe [:map-of :string :any]]} + [cfg {:keys [::rpc/profile-id file-id]}] + (bfc/check-file-exists cfg file-id) + (check-read-permissions! cfg profile-id file-id) + (let [{:keys [data]} (bfc/get-file cfg file-id)] + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] + (when-let [tokens-lib (cfo/get-tokens-lib data)] + (ctob/export-dtcg-json tokens-lib (cfo/get-tokens-status data)))))) + ;; --- COMMAND QUERY: get-file-libraries diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 8feb0516fb..305108003c 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2929,3 +2929,68 @@ (t/is (not (nil? (:error out)))) (let [edata (-> out :error ex-data)] (t/is (= :not-found (:type edata)))))) + +(t/deftest get-file-tokens-without-tokens + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :get-file-tokens + ::rpc/profile-id (:id profile) + :file-id (:id file)})] + (t/is (nil? (:error out))) + (t/is (nil? (:result out))))) + +(t/deftest get-file-tokens-with-tokens + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + set-id (uuid/next) + token-id (uuid/next)] + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + [{:type :set-token-set + :id set-id + :attrs {:id set-id :name "brand-core"}} + {:type :set-token + :set-id set-id + :token-id token-id + :attrs {:id token-id + :name "color.primary-base" + :type :color + :value "#ff0000"}}]) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 1 + :changes + [{:type :set-tokens-status + :theme-ids #{} + :set-ids #{set-id}}]) + + (let [out (th/command! {::th/type :get-file-tokens + ::rpc/profile-id (:id profile) + :file-id (:id file)}) + dtcg (:result out)] + (t/is (nil? (:error out))) + (t/is (= "#ff0000" (get-in dtcg ["brand-core" "color" "primary-base" "$value"]))) + (t/is (= "color" (get-in dtcg ["brand-core" "color" "primary-base" "$type"]))) + (t/is (= ["brand-core"] (get-in dtcg ["$metadata" "tokenSetOrder"]))) + (t/is (= #{"brand-core"} (set (get-in dtcg ["$metadata" "activeSets"]))))))) + +(t/deftest get-file-tokens-forbidden + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false}) + out (th/command! {::th/type :get-file-tokens + ::rpc/profile-id (:id other) + :file-id (:id file)})] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :not-found)))) From d33b59b0f7236a09adee5908996da8be3996327d Mon Sep 17 00:00:00 2001 From: Surfoo Date: Fri, 9 Oct 2026 18:05:52 +0200 Subject: [PATCH 080/325] :globe_with_meridians: Add translations for: French Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/ --- frontend/translations/fr.po | 182 +++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 3 deletions(-) diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 24bb023ce1..becda2c0a6 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-30 16:51+0000\n" -"Last-Translator: imagoiq \n" +"PO-Revision-Date: 2026-10-10 16:51+0000\n" +"Last-Translator: Surfoo \n" "Language-Team: French \n" "Language: fr\n" @@ -9,7 +9,7 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n!=1);\n" -"X-Generator: Weblate 2026.10.dev0\n" +"X-Generator: Weblate 2026.10.1.dev0\n" #: src/app/main/ui/auth/register.cljs:227, src/app/main/ui/static.cljs:191, src/app/main/ui/viewer/login.cljs:119 msgid "auth.already-have-account" @@ -9518,3 +9518,179 @@ msgid "workspace.options.font-not-available" msgstr "" "La police utilisée par ce texte (%s) n'est plus disponible. Téléverser à " "nouveau la police pour créer un style typographique" + +#: src/app/main/ui/inspect/attributes/text.cljs:117 +msgid "inspect.attributes.typography.text-decoration.strikethrough" +msgstr "Barré" + +#: src/app/main/ui/inspect/right_sidebar.cljs:176 +msgid "inspect.tabs-switcher-label" +msgstr "Changer d'onglet d'inspection" + +#: src/app/main/ui/workspace/colorpicker.cljs:447 +msgid "workspace.colorpicker.color-picker" +msgstr "Prélever une couleur à l'écran" + +#: src/app/main/data/plugins.cljs:158 +msgid "workspace.plugins.error.unreachable" +msgstr "L'URL de l'extension est inaccessible." + +#: src/app/main/ui/workspace/top_toolbar.cljs:413 +msgid "workspace.toolbar.label" +msgstr "Outils de dessin" + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "Connecter les bibliothèques sélectionnées" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Nom du fichier" + +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "Fichier suivant" + +#: src/app/main/ui/dashboard/import.cljs:459 +msgid "dashboard.import.resolve-libraries.connect-to" +msgstr "connecter à" + +#: src/app/main/ui/dashboard/import.cljs:453 +msgid "dashboard.import.resolve-libraries.original-library" +msgstr "bibliothèque d'origine" + +#: src/app/main/ui/dashboard/import.cljs:529 +msgid "dashboard.import.summary.manually-linked" +msgstr "lié manuellement" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "Nouveau" + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "Original" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "Aucune organisation pour l'instant." + +#: src/app/main/ui/auth/login.cljs:113 +msgid "errors.account-locked" +msgstr "" +"Trop de tentatives échouées. Votre compte est verrouillé pendant %s minutes. " +"Veuillez réessayer plus tard." + +#: src/app/main/errors.cljs:499 +msgid "errors.connection-error" +msgstr "" +"Impossible de joindre le serveur. Vérifiez votre connexion et réessayez." + +#: src/app/main/data/persistence.cljs:307 +msgid "errors.save-retrying" +msgstr "" +"Connexion perdue. Nouvelle tentative d'enregistrement de vos modifications." + +#: src/app/main/ui/dashboard/import.cljs:703 +msgid "labels.skip" +msgstr "Passer" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:562 +msgid "labels.team-management" +msgstr "Gestion de l'équipe" + +#: src/app/main/ui/workspace/left_header.cljs:125 +msgid "workspace.header.retrying" +msgstr "Nouvelle tentative d'enregistrement" + +#: src/app/main/ui/workspace/libraries.cljs:842 +msgid "workspace.libraries.connected-libraries" +msgstr "Bibliothèque connectée" + +#: src/app/main/ui/workspace/libraries.cljs:183 +msgid "workspace.libraries.connected-through" +msgstr "Connectée via" + +#: src/app/main/ui/workspace/libraries.cljs:812 +msgid "workspace.libraries.empty.no-assets" +msgstr "Aucune ressource pour l'instant" + +#: src/app/main/ui/workspace/libraries.cljs:895 +msgid "workspace.libraries.empty.no-connected-libraries" +msgstr "Aucune bibliothèque connectée pour l'instant." + +#: src/app/main/ui/workspace/libraries.cljs:827, src/app/main/ui/workspace/libraries.cljs:874 +msgid "workspace.libraries.set-as-tokens-source" +msgstr "Définir comme source des tokens" + +#: src/app/main/ui/workspace/libraries.cljs:621 +msgid "workspace.libraries.shared-library-added" +msgstr "Ajoutée" + +#: src/app/main/ui/workspace/libraries.cljs:167, src/app/main/ui/workspace/libraries.cljs:211 +msgid "workspace.libraries.token-sets" +msgid_plural "workspace.libraries.token-sets" +msgstr[0] "1 collection" +msgstr[1] "%s collections" + +#: src/app/main/ui/workspace/libraries.cljs:170, src/app/main/ui/workspace/libraries.cljs:215 +msgid "workspace.libraries.token-themes" +msgid_plural "workspace.libraries.token-themes" +msgstr[0] "1 thème" +msgstr[1] "%s thèmes" + +#: src/app/main/ui/workspace/libraries.cljs:164, src/app/main/ui/workspace/libraries.cljs:207 +msgid "workspace.libraries.tokens" +msgid_plural "workspace.libraries.tokens" +msgstr[0] "1 token" +msgstr[1] "%s tokens" + +#: src/app/main/ui/workspace/libraries.cljs:820, src/app/main/ui/workspace/libraries.cljs:866 +msgid "workspace.libraries.tokens-source" +msgstr "Source des tokens" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:75 +msgid "workspace.options.canvas-background-section" +msgstr "Section du fond du canevas" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:99, src/app/main/ui/workspace/sidebar/options/page.cljs:106 +msgid "workspace.options.pixel-grid-color" +msgstr "Couleur de la grille de pixels" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:106 +msgid "workspace.tokens.add-set" +msgstr "Ajouter une collection" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:98 +msgid "workspace.tokens.change-token-source" +msgstr "Changer la source des tokens" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:101 +msgid "workspace.tokens.current-tokens-source-tooltip" +msgstr "C'est la source actuelle des tokens" + +#: src/app/main/data/workspace/libraries.cljs:847 +msgid "workspace.tokens.notifications.source-sets-or-themes-updated" +msgstr "Les collections ou thèmes de la source des tokens ont été modifiés" + +#: src/app/main/ui/workspace/colorpicker.cljs:438, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:67 +msgid "workspace.tokens.only-first-fill-or-stroke" +msgstr "" +"Les design tokens ne peuvent s'appliquer qu'au premier remplissage ou " +"contour de la liste." + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:104 +msgid "workspace.tokens.open-source-new-tab" +msgstr "Ouvrir la source des tokens dans un nouvel onglet" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:311, src/app/main/ui/workspace/tokens/management/context_menu.cljs:335 +msgid "workspace.tokens.stroke-width" +msgstr "Largeur du contour" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:75 +msgid "workspace.tokens.source" +msgstr "Source :" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:80 +msgid "workspace.tokens.this-file" +msgstr "Ce fichier" From 5eb33add7ee7c15c4cbeab67b0917773ccbdcb63 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sun, 11 Oct 2026 18:48:02 +0200 Subject: [PATCH 081/325] :sparkles: Add skip-if-active option to jobs submit (#12266) * :sparkles: Add skip-if-active option to jobs submit Producers that need at most one live job per key can now pass ::jobs/skip-if-active: the submit counts live jobs for the same tenant, name, queue and label in the same transaction and skips the insert (returning nil) when one lives. The option requires an explicit label and documents the same best-effort race caveat as the dedupe it complements. Closes #12265 AI-assisted-by: muse-spark-1.3-contributor * :sparkles: Record skipped job submissions as a metric Skip-if-active submissions withheld nothing observable: the skip now records penpot_jobs_skipped_total (name, queue) instead of the submitted counter, so dashboards can tell withheld work apart from new work. The insert path is untouched. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Drop jobs counters duplicated by histogram counts The completed and requests counters counted exactly the same events as the _count of their timing histograms, so they go away: terminal totals now come from penpot_jobs_total_ms_count by outcome and request totals from penpot_jobs_request_ms_count by outcome. One event, one family from here on: counters only cover events without latency, and the dispatched vs queue-wait pair stays because pushes and claims are different events. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/backend/subtleties.md | 3 +- backend/src/app/jobs.clj | 77 ++++++++++++++++--- backend/src/app/jobs/metrics.clj | 16 ++-- backend/src/app/main.clj | 26 +++---- backend/src/app/worker/dispatcher.clj | 1 - .../test/backend_tests/jobs_metrics_test.clj | 45 ++++++++--- .../test/backend_tests/jobs_request_test.clj | 31 +++++--- backend/test/backend_tests/jobs_test.clj | 63 +++++++++++++++ .../backend_tests/worker_dispatcher_test.clj | 6 +- 9 files changed, 204 insertions(+), 64 deletions(-) diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 3e1b73a482..4d066393b2 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -24,7 +24,8 @@ ## Workers and cron - `job` carries `tenant` (the instance the job belongs to) and a bare `queue`. Several instances share one database and the tenant is what keeps an instance from claiming another environment's jobs, so every query that acts on one instance's work filters on `tenant`: dispatch claim, orphan sweep, lost-job reschedule, cron no-overlap count, submit dedupe. The `job__dispatcher__idx` leads with `tenant` (equality) then `(priority DESC, scheduled_at)`; `queue` does not lead because the dispatcher claims every queue of its tenant. The `:` prefix survives only in the Redis key, composed once by `app.worker/queue-key`. -- Submit dedupe only removes not-yet-due `new` jobs of the same tenant with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. +- Submit dedupe only removes not-yet-due `new` jobs of the same tenant with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. The `::jobs/skip-if-active` submit option goes further: it skips the insert (returning `nil`, recording `penpot_jobs_skipped_total`) when a live job (`new`/`scheduled`/`running`/`retry`) already exists for the same tenant/name/queue/label, requires an explicit non-blank label, and carries the same best-effort cross-backend race caveat as dedupe. +- Jobs metrics rule (one event, one family): counters exist only for events without latency; when an event has a histogram, its `_count` is the counter (no twin counter). `dispatched` (pushes) vs `queue-wait` (claims) are deliberately different events — do not merge them. - The dispatcher selects `new`/`retry` jobs with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes the JSON payload `[id scheduled-at]` to the `penpot.worker.queue::` Redis list. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. - Lost `scheduled` jobs are rescheduled after 5 minutes; `running` jobs untouched longer than `:jobs-lease` (default 30 min) are marked `aborted` with `app.jobs/orphan-error` (`type :internal`, `code :orphan`). `aborted` is a system-side terminal state like `cancelled` (never retried, swept by retention like other terminals, reported with an error log); long jobs must `heartbeat` per chunk so a healthy backlog is never mistaken for a dead process. The `:progress` report is optional and only feeds the durable log. A beat whose write finds the job no longer active raises an `:interrupt` (`code :job-interrupted`) instead of counting zero: it is the internal stop-the-work signal, not an error, so neither the listener nor the runner logs it as one. - An orphan gets its `end` event (`outcome "aborted"`) from the sweep itself: one bulk `INSERT ... SELECT` in the same transaction as the bulk `UPDATE`, never one insert per orphan. No msgbus notification goes out for it; orphans alert through the error log and the orphaned counter. diff --git a/backend/src/app/jobs.clj b/backend/src/app/jobs.clj index be36f25549..19df02cc9b 100644 --- a/backend/src/app/jobs.clj +++ b/backend/src/app/jobs.clj @@ -245,6 +245,19 @@ AND status = 'new' AND scheduled_at > ?") +;; The cron no-overlap check and the skip-if-active submit option share +;; this shape: both look for jobs of one tenant with the same name, queue +;; and label that have not reached a terminal state yet. The +;; `job__name_label__idx` partial index serves both queries. +(def ^:private sql:count-active-jobs + "SELECT count(*) AS n + FROM job + WHERE name = ? + AND tenant = ? + AND queue = ? + AND label = ? + AND status IN ('new', 'scheduled', 'running', 'retry')") + (def ^:private schema:options [:map {:title "submit-options"} [::name schema:job-name] @@ -255,6 +268,10 @@ [::priority {:optional true} ::sm/int] [::max-retries {:optional true} ::sm/int] [::dedupe {:optional true} ::sm/boolean] + ;; Skip the insert when a live job already covers the same + ;; (tenant, name, queue, label). Requires an explicit label: the + ;; default empty one would block every flagged submission at once. + [::skip-if-active {:optional true} ::sm/boolean] ;; Owner of a user-facing job. Its events are published on the msgbus ;; topic of this profile; internal jobs omit it. [::profile-id {:optional true} ::sm/uuid] @@ -289,17 +306,36 @@ `::expires-at`) are validated and stored as columns; nothing is inferred from `params`. + With `::skip-if-active`, the insert is skipped (and `nil` returned) + when a live job (`new`, `scheduled`, `running` or `retry`) already + exists for the same tenant, name, queue and label. The check and the + insert share one transaction. A skip deletes nothing and inserts + nothing; it records a skipped metric instead of a submitted one, so + dashboards can tell withheld work apart from new work. The option + needs an explicit non-blank `::label`, otherwise it raises. + NOTE: the dedupe DELETE and the INSERT run atomically: joined to the caller's transaction when the cfg provides `::db/conn`, wrapped in their own transaction otherwise. Concurrent cross-backend submissions can, in rare race conditions, produce duplicated 'new' - rows (accepted risk, see prod-infra documentation)." + rows (accepted risk, see prod-infra documentation). The same caveat + covers the skip-if-active check: a race between backends can slip + two inserts past the count." [cfg {:keys [::params ::name ::delay ::queue ::priority ::max-retries - ::dedupe ::label ::profile-id ::resource-id ::expires-at] + ::dedupe ::skip-if-active ::label + ::profile-id ::resource-id ::expires-at] :or {delay 0 queue :default priority 100 max-retries 3 label ""} :as options}] (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") + ;; Before the schema check so the failure names the option, not the + ;; shape: the schema would reject a blank label with a generic + ;; "check error" either way. + (when (and skip-if-active (or (not (string? label)) (str/blank? label))) + (ex/raise :type :validation + :code :skip-if-active-requires-label + :hint "skip-if-active requires an explicit non-blank label" + :name (d/name name))) (check-options options) (let [job-def (get-job-def (get-defs cfg) name) params (validate-params job-def params) @@ -327,6 +363,14 @@ ;; between backends could create duplicates, but this is ;; acceptable: cross-backend races are rare, jobs are idempotent, ;; and dedupe is best-effort. + ;; Skip-if-active is best-effort like dedupe: the count and the + ;; insert share this transaction, but a race between backends + ;; can still slip two inserts past the count. + active? (fn [conn] + (when skip-if-active + (pos? (or (:n (db/exec-one! conn [sql:count-active-jobs + job-name tenant queue label])) + 0)))) insert! (fn [conn] (let [deleted (when dedupe ;; the DELETE below is intentional: @@ -353,13 +397,23 @@ ;; caller's transaction when the cfg provides a connection, ;; wrapped in their own otherwise (a failed INSERT can never ;; orphan a committed DELETE, even on an autocommit caller conn). + ;; A skip returns nil: no DELETE, no INSERT, no submitted metric. (db/tx-run! cfg (fn [{:keys [::db/conn]}] - (let [result (insert! conn)] - (db/after-commit - #(metrics/record-submitted cfg job-name queue)) - result))) - id)) + (if (active? conn) + (do (db/after-commit + #(metrics/record-skipped cfg job-name queue)) + (l/trc :hint "submit skipped, active job exists" + :name job-name + :tenant tenant + :queue queue + :label label + :skipped true)) + (let [result (insert! conn)] + (db/after-commit + #(metrics/record-submitted cfg job-name queue)) + result + id)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; JOB API @@ -845,13 +899,14 @@ nil))) (defn- record-terminal - "Record the terminal outcome and the total time. Runs after the commit + "Record the total time of a terminal outcome. Runs after the commit that made the job terminal, so a rolled back transition is never - counted. Takes the cfg and resolves the metrics instance from it, so - the callback is not tied to an instance read at registration time." + counted. The histogram `_count` is the terminal total per outcome: + no separate counter. Takes the cfg and resolves the metrics + instance from it, so the callback is not tied to an instance read + at registration time." [cfg job outcome] (when job - (metrics/record-outcome cfg (:name job) (:queue job) outcome) (when (ct/inst? (:created-at job)) (metrics/record-total cfg diff --git a/backend/src/app/jobs/metrics.clj b/backend/src/app/jobs/metrics.clj index a1dd8a2343..5ba7df2864 100644 --- a/backend/src/app/jobs/metrics.clj +++ b/backend/src/app/jobs/metrics.clj @@ -65,19 +65,18 @@ [(name-label name) (queue-label queue)] 1)) +(defn record-skipped + [cfg name queue] + (record-count (mtx/instance cfg) :jobs-skipped + [(name-label name) (queue-label queue)] + 1)) + (defn record-dispatched [cfg name queue amount] (record-count (mtx/instance cfg) :jobs-dispatched [(name-label name) (queue-label queue)] amount)) -(defn record-outcome - [cfg name queue outcome] - (record-count (mtx/instance cfg) :jobs-completed - [(name-label name) (queue-label queue) - (outcome-label outcome)] - 1)) - (defn record-retry [cfg name queue reason] (record-count (mtx/instance cfg) :jobs-retries @@ -172,7 +171,8 @@ [cfg outcome millis] (let [outcome (str/lower (or (some-> outcome d/name) "error")) metrics (mtx/instance cfg)] - (record-count metrics :jobs-requests-total [outcome] 1) + ;; No separate counter: the timing `_count` is the request total + ;; per outcome. (record-value metrics :jobs-request-timing [outcome] (max 0 (long millis))))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 87d0b31f29..619fa9a150 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -240,16 +240,16 @@ ::mdef/labels ["name" "queue"] ::mdef/type :counter} - :jobs-dispatched - {::mdef/name "penpot_jobs_dispatched_total" - ::mdef/help "Total number of jobs pushed to a worker queue." + :jobs-skipped + {::mdef/name "penpot_jobs_skipped_total" + ::mdef/help "Total number of durable job submissions skipped by skip-if-active." ::mdef/labels ["name" "queue"] ::mdef/type :counter} - :jobs-completed - {::mdef/name "penpot_jobs_completed_total" - ::mdef/help "Total number of durable jobs reaching a terminal state." - ::mdef/labels ["name" "queue" "outcome"] + :jobs-dispatched + {::mdef/name "penpot_jobs_dispatched_total" + ::mdef/help "Total number of jobs pushed to a worker queue. Counts pushes, not claims: compare with the _count of penpot_jobs_queue_wait_ms." + ::mdef/labels ["name" "queue"] ::mdef/type :counter} :jobs-retries @@ -277,7 +277,7 @@ :jobs-queue-wait-timing {::mdef/name "penpot_jobs_queue_wait_ms" - ::mdef/help "Time from job scheduling to worker claim (milliseconds)." + ::mdef/help "Time from job scheduling to worker claim (milliseconds). Its _count counts claims, not pushes: compare with penpot_jobs_dispatched_total." ::mdef/labels ["name" "queue"] ::mdef/type :histogram ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000 600000]} @@ -291,7 +291,7 @@ :jobs-total-timing {::mdef/name "penpot_jobs_total_ms" - ::mdef/help "Time from job creation to a terminal state (milliseconds)." + ::mdef/help "Time from job creation to a terminal state (milliseconds). Its _count is the terminal total per outcome." ::mdef/labels ["name" "queue" "outcome"] ::mdef/type :histogram ::mdef/buckets [10 50 100 500 1000 5000 10000 30000 60000 300000 600000 3600000]} @@ -340,15 +340,9 @@ ::mdef/labels ["outcome" "reason"] ::mdef/type :counter} - :jobs-requests-total - {::mdef/name "penpot_jobs_requests_total" - ::mdef/help "Total number of ephemeral job request events." - ::mdef/labels ["outcome"] - ::mdef/type :counter} - :jobs-request-timing {::mdef/name "penpot_jobs_request_ms" - ::mdef/help "Time spent waiting for an ephemeral job reply (milliseconds)." + ::mdef/help "Time spent waiting for an ephemeral job reply (milliseconds). Its _count is the request total per outcome." ::mdef/labels ["outcome"] ::mdef/type :histogram ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]}}) diff --git a/backend/src/app/worker/dispatcher.clj b/backend/src/app/worker/dispatcher.clj index 3ade655db3..b084966714 100644 --- a/backend/src/app/worker/dispatcher.clj +++ b/backend/src/app/worker/dispatcher.clj @@ -135,7 +135,6 @@ RETURNING job.id, job.queue, job.name, job.created_at") (fn [] (doseq [{:keys [id queue name created-at]} rows] (metrics/record-orphan cfg queue) - (metrics/record-outcome cfg name queue :aborted) (when (ct/inst? created-at) (metrics/record-total cfg name queue :aborted (- (inst-ms timestamp) (inst-ms created-at)))) diff --git a/backend/test/backend_tests/jobs_metrics_test.clj b/backend/test/backend_tests/jobs_metrics_test.clj index 45f4b59725..7b2523c320 100644 --- a/backend/test/backend_tests/jobs_metrics_test.clj +++ b/backend/test/backend_tests/jobs_metrics_test.clj @@ -33,8 +33,8 @@ (def ^:private metric-ids [:jobs-submitted + :jobs-skipped :jobs-dispatched - :jobs-completed :jobs-retries :jobs-orphaned :jobs-rescheduled @@ -48,7 +48,6 @@ :jobs-gc-rows :jobs-gc-timing :jobs-cron-total - :jobs-requests-total :jobs-request-timing :jobs-events-total]) @@ -103,7 +102,7 @@ (t/is (some? definition) id) (t/is (re-find #"^penpot_jobs_" (::mdef/name definition)) id))) (t/is (= ["name" "queue" "outcome"] - (::mdef/labels (:jobs-completed main/default-metrics)))) + (::mdef/labels (:jobs-total-timing main/default-metrics)))) (t/is (= ["status"] (::mdef/labels (:jobs-backlog main/default-metrics))))) @@ -140,14 +139,37 @@ (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) (jobs/complete cfg :job-id job-id :result {:ok true}) (t/is (= 1.0 (counter-value metrics :jobs-submitted ["echo" "default"]))) - (t/is (= 1.0 (counter-value metrics :jobs-completed ["echo" "default" "completed"]))))) + (t/is (= 1.0 (histogram-count metrics :jobs-total-timing ["echo" "default" "completed"]))))) + +(t/deftest skip-if-active-records-skipped-metric + (let [metrics (make-metrics) + defs {:echo {::jobs/name :echo + ::jobs/schema [:map] + ::jobs/handler (fn [_context params] params) + ::jobs/decoder identity + ::jobs/validator (constantly true)}} + cfg {::jobs/defs defs + ::db/pool th/*pool* + ::mtx/metrics metrics} + _ (jobs/submit cfg {::jobs/name :echo + ::jobs/params {} + ::jobs/label "skip-metric"}) + skipped (jobs/submit cfg {::jobs/name :echo + ::jobs/params {} + ::jobs/label "skip-metric" + ::jobs/skip-if-active true})] + (t/is (nil? skipped)) + (t/is (= 1.0 (counter-value metrics :jobs-skipped ["echo" "default"]))) + (t/is (= 1.0 (counter-value metrics :jobs-submitted ["echo" "default"])) + "the withheld submit records no submission"))) (t/deftest lifecycle-helpers-use-labels-as-is (let [metrics (make-metrics) cfg (metrics-cfg metrics)] (jobs-metrics/record-submitted cfg "delete-object" "default") + (jobs-metrics/record-skipped cfg "delete-object" "default") (jobs-metrics/record-dispatched cfg "delete-object" "default" 2) - (jobs-metrics/record-outcome cfg "delete-object" "default" :completed) + (jobs-metrics/record-total cfg "delete-object" "default" :completed 42) (jobs-metrics/record-retry cfg "delete-object" "default" :backoff) (jobs-metrics/record-orphan cfg "webhooks") (jobs-metrics/record-rescheduled cfg "custom") @@ -157,14 +179,15 @@ (jobs-metrics/record-legacy-execution cfg "delete-object" 42) (t/is (= 1.0 (counter-value metrics :jobs-submitted ["delete-object" "default"]))) + (t/is (= 1.0 (counter-value metrics :jobs-skipped ["delete-object" "default"]))) (t/is (= 2.0 (counter-value metrics :jobs-dispatched ["delete-object" "default"]))) - (t/is (= 1.0 (counter-value metrics :jobs-completed ["delete-object" "default" "completed"]))) + (t/is (= 1.0 (histogram-count metrics :jobs-total-timing ["delete-object" "default" "completed"]))) (t/is (= 1.0 (counter-value metrics :jobs-retries ["delete-object" "default" "backoff"]))) (t/is (= 1.0 (counter-value metrics :jobs-orphaned ["webhooks"]))) (t/is (= 1.0 (counter-value metrics :jobs-rescheduled ["custom"]))) (t/is (= 3.0 (counter-value metrics :jobs-gc-rows ["expired" "deleted"]))) (t/is (= 1.0 (counter-value metrics :jobs-cron-total ["submitted" "none"]))) - (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["replied"]))) + (t/is (= 1.0 (histogram-count metrics :jobs-request-timing ["replied"]))) (t/testing "the legacy histogram keeps the raw job name, with no queue" (t/is (= 1.0 (histogram-count metrics :tasks-timing ["delete-object"])))) @@ -181,15 +204,15 @@ (t/is (= "other" (jobs-metrics/queue-label nil)))) (let [metrics (make-metrics) cfg (metrics-cfg metrics)] - (jobs-metrics/record-outcome cfg "echo" "unexpected" :unexpected) - (t/is (= 1.0 (counter-value metrics :jobs-completed ["echo" "unexpected" "unexpected"]))) + (jobs-metrics/record-total cfg "echo" "unexpected" :unexpected 42) + (t/is (= 1.0 (histogram-count metrics :jobs-total-timing ["echo" "unexpected" "unexpected"]))) (t/testing "a new value is recorded, never folded or dropped" (jobs-metrics/record-gc-rows cfg :unknown :deleted 1) (jobs-metrics/record-cron cfg :brand-new :whatever) (jobs-metrics/record-request cfg :brand-new 5) (t/is (= 1.0 (counter-value metrics :jobs-gc-rows ["unknown" "deleted"]))) (t/is (= 1.0 (counter-value metrics :jobs-cron-total ["brand-new" "whatever"]))) - (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["brand-new"])))))) + (t/is (= 1.0 (histogram-count metrics :jobs-request-timing ["brand-new"])))))) (t/deftest sampler-is-wired-and-does-not-start-on-read-only (t/is (contains? main/worker-config :app.jobs.metrics/sampler)) @@ -279,7 +302,7 @@ (t/is (= 2 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job_event"]))))) (t/testing "the terminal counter followed its own commit, not the caller one" - (t/is (= 1.0 (counter-value metrics :jobs-completed ["echo" "default" "completed"])))))) + (t/is (= 1.0 (histogram-count metrics :jobs-total-timing ["echo" "default" "completed"])))))) (t/deftest progress-counter-follows-the-report-that-outlives-the-caller (let [metrics (make-metrics) diff --git a/backend/test/backend_tests/jobs_request_test.clj b/backend/test/backend_tests/jobs_request_test.clj index bb45c500b3..976102def1 100644 --- a/backend/test/backend_tests/jobs_request_test.clj +++ b/backend/test/backend_tests/jobs_request_test.clj @@ -23,8 +23,6 @@ [integrant.core :as ig]) (:import io.lettuce.core.api.sync.RedisCommands - io.prometheus.client.Counter - io.prometheus.client.Counter$Child java.lang.AutoCloseable)) (t/use-fixtures :once th/state-init) @@ -36,8 +34,7 @@ (defn- make-metrics [] (ig/init-key :app.metrics/metrics {:default (select-keys main/default-metrics - [:jobs-requests-total - :jobs-request-timing])})) + [:jobs-request-timing])})) (defn- make-pool ([] (make-pool (get th/*system* :app.metrics/metrics))) @@ -131,11 +128,21 @@ ;; TESTS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- counter-value [metrics id labels] - (let [collector (mtx/get-collector metrics id) - instance (::mdef/instance collector) - child (.labels ^Counter instance (into-array String labels))] - (.get ^Counter$Child child))) +(defn- histogram-count [metrics id labels] + (let [definition (get main/default-metrics id)] + (->> (enumeration-seq + (.metricFamilySamples ^io.prometheus.client.CollectorRegistry + (mtx/get-registry metrics))) + (filter (fn [^io.prometheus.client.Collector$MetricFamilySamples family] + (= (.-name family) (::mdef/name definition)))) + (mapcat (fn [^io.prometheus.client.Collector$MetricFamilySamples family] + (.samples family))) + (filter (fn [^io.prometheus.client.Collector$MetricFamilySamples$Sample sample] + (and (str/ends-with? (.-name sample) "_count") + (= labels (vec (.-labelValues sample)))))) + (map (fn [^io.prometheus.client.Collector$MetricFamilySamples$Sample sample] + (.-value sample))) + (reduce + 0.0)))) (t/deftest request-pool-keeps-its-own-metrics (let [metrics-a (make-metrics) @@ -154,14 +161,14 @@ (jobs/request cfg {::jobs/queue :media ::jobs/cmd :process ::jobs/params {}}))) - (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["replied"]))) + (t/is (= 1.0 (histogram-count metrics :jobs-request-timing ["replied"]))) (t/is (thrown-with-msg? Exception #"timeout waiting for the job reply" (jobs/request cfg {::jobs/queue :media ::jobs/cmd :process ::jobs/params {} ::jobs/timeout (ct/duration {:millis 100})}))) - (t/is (= 1.0 (counter-value metrics :jobs-requests-total ["timeout"]))) - (t/is (= 0.0 (counter-value metrics :jobs-requests-total ["sent"]))))) + (t/is (= 1.0 (histogram-count metrics :jobs-request-timing ["timeout"]))) + (t/is (= 0.0 (histogram-count metrics :jobs-request-timing ["sent"]))))) (t/deftest request-returns-the-ok-reply (let [pool (make-pool) diff --git a/backend/test/backend_tests/jobs_test.clj b/backend/test/backend_tests/jobs_test.clj index 20675fed2b..5dee48bbb3 100644 --- a/backend/test/backend_tests/jobs_test.clj +++ b/backend/test/backend_tests/jobs_test.clj @@ -507,6 +507,69 @@ AND tenant = ?" label (cf/get :tenant)]))))))) +(t/deftest submit-skip-if-active-skips-live-jobs + (doseq [status ["new" "scheduled" "running" "retry"]] + (t/testing (str "live status " status) + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label (str "skip-active-" status) + id1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label}) + _ (th/db-update! :job {:status status} {:id id1}) + id2 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/skip-if-active true})] + (t/is (nil? id2)) + (t/testing "no row deleted, none inserted" + (t/is (some? (jobs/get-job cfg id1))) + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ?" label]))))))))) + +(t/deftest submit-skip-if-active-inserts-past-terminal-jobs + (doseq [status ["completed" "failed" "cancelled" "aborted"]] + (t/testing (str "terminal status " status) + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label (str "skip-terminal-" status) + id1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label}) + _ (th/db-update! :job {:status status} {:id id1}) + id2 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/skip-if-active true})] + (t/is (uuid? id2)) + (t/is (= 2 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ?" label])))))))) + +(t/deftest submit-skip-if-active-requires-an-explicit-label + (let [cfg (make-cfg (get-job-defs)) + params (make-params)] + (t/is (thrown-with-msg? Exception #"skip-if-active requires" + (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/skip-if-active true}))) + (t/is (thrown-with-msg? Exception #"skip-if-active requires" + (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label " " + ::jobs/skip-if-active true}))))) + +(t/deftest submit-skip-if-active-inserts-when-nothing-is-live + (let [cfg (make-cfg (get-job-defs)) + params (make-params) + label "skip-idle-label" + id1 (jobs/submit cfg {::jobs/name :echo + ::jobs/params params + ::jobs/label label + ::jobs/skip-if-active true})] + (t/is (uuid? id1)) + (t/is (= 1 (:cnt (th/db-exec-one! ["SELECT count(*) AS cnt FROM job + WHERE label = ?" label])))))) + (t/deftest submit-dedupe-rolls-back-on-insert-failure (let [cfg (make-cfg (get-job-defs)) params (make-params) diff --git a/backend/test/backend_tests/worker_dispatcher_test.clj b/backend/test/backend_tests/worker_dispatcher_test.clj index 02064f2774..c498214beb 100644 --- a/backend/test/backend_tests/worker_dispatcher_test.clj +++ b/backend/test/backend_tests/worker_dispatcher_test.clj @@ -52,8 +52,7 @@ (defn- make-orphan-metrics [] (ig/init-key :app.metrics/metrics {:default (select-keys main/default-metrics - [:jobs-orphaned :jobs-completed - :jobs-total-timing])})) + [:jobs-orphaned :jobs-total-timing])})) (defn- counter-value [metrics id labels] (let [collector (mtx/get-collector metrics id) @@ -228,8 +227,7 @@ (ct/duration {:minutes 1})))})] (wdisp/run-batch cfg) (t/is (= 1.0 (counter-value metrics :jobs-orphaned ["test"]))) - (t/is (= 1.0 (counter-value metrics :jobs-completed - ["test-job" "test" "aborted"]))))) + (t/is (= 1 (histogram-sample-count metrics ["test-job" "test" "aborted"]))))) (t/deftest dispatcher-batch-without-pending-jobs-signals-wait (let [cfg (mk-cfg)] From 2965a4c0434972d62cb9a28187b104e0a8e75719 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 13 Oct 2026 09:33:06 +0200 Subject: [PATCH 082/325] :sparkles: Remove duplicated nitrate log type (#12275) --- backend/src/app/loggers/audit/archive_task.clj | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 892c24895d..1264882eaa 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -80,7 +80,6 @@ "add-member-to-organization" "add-team-to-organization" "cancel-organization-invitation" - "change-organization-advanced-permission" "create-export-binfile-job" "create-file" "create-organization" From 6f34794f547fabd8999a07ddd8627e4f9c3c9952 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Tue, 13 Oct 2026 12:54:48 +0200 Subject: [PATCH 083/325] :bug: Fix render-wasm build arguments and artifact selection (#12254) Forward Cargo arguments as an array and honor explicit profiles. Build and copy each renderer target once under the repository lock, with watch rebuilds using the same wrapper. Select this checkout's compiler artifact and matching generated enum table from Cargo JSON. Record actual features, profile and output paths, and allow callers to override publication destinations. Run the existing Cargo parser tests before the renderer's native tests and stop on parser failures. AI-assisted-by: gpt-6.1-sol --- render-wasm/_build_env | 152 ++++++---- render-wasm/build | 4 +- render-wasm/scripts/cargo-build.cjs | 120 ++++++++ render-wasm/scripts/cargo-build.test.cjs | 349 +++++++++++++++++++++++ render-wasm/test | 4 +- render-wasm/watch | 17 +- 6 files changed, 589 insertions(+), 57 deletions(-) create mode 100644 render-wasm/scripts/cargo-build.cjs create mode 100644 render-wasm/scripts/cargo-build.test.cjs diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 11c192fbb2..ce5e34d1d5 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -2,7 +2,7 @@ export RUST_VERSION=1.99.0 -export VERSION_TAG=${VERSION:-develop}; +export VERSION_TAG="${VERSION_TAG:-${VERSION:-develop}}"; export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}"; case "$RENDER_TARGET" in @@ -13,15 +13,15 @@ case "$RENDER_TARGET" in ;; esac -if [ "$NODE_ENV" = "production" ]; then - export BUILD_MODE="release"; +if [ "${NODE_ENV:-}" = "production" ]; then + export BUILD_MODE="${BUILD_MODE:-release}"; else export BUILD_MODE=${BUILD_MODE:-debug}; fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" -export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; -export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; +export CARGO_BUILD_TARGET="${CARGO_BUILD_TARGET:-wasm32-unknown-emscripten}"; +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-target/$RENDER_TARGET}"; # Skia prebuild with wasm SIMD and Wasm EH/SjLj (-ljmp-wasm). Must match # EMCC_CFLAGS below. Override with SKIA_BINARIES_URL when needed. export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.153.3/skia-binaries-b7f043e0b1e2a850e702-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp-pdf-simd-ljmp-wasm.tar.gz"} @@ -75,43 +75,82 @@ export EMCC_CFLAGS="--no-entry \ export EM_CACHE="/tmp/emsdk_cache"; -export CARGO_PARAMS="${@:2}"; - +CARGO_ARGS=("${@:2}"); +_CARGO_PROFILE_SET=0; export CARGO_PROFILE_DIR="debug"; -if [ "$BUILD_MODE" = "profiling" ]; then - # Optimized like release, but keeps the wasm name section so the Firefox - # profiler can resolve Rust frames (release strips them). `--profiling-funcs` - # preserves function names through wasm-opt without pulling in DWARF. - export CARGO_PARAMS="--profile profiling $CARGO_PARAMS"; - export CARGO_PROFILE_DIR="profiling"; - export EMCC_CFLAGS="-O3 -sASSERTIONS=0 --profiling-funcs $EMCC_CFLAGS"; -elif [ "$BUILD_MODE" = "release" ]; then - case "$RENDER_TARGET" in - frontend) - export CARGO_PARAMS="--release $CARGO_PARAMS"; +for (( _cargo_index=0; _cargo_index < ${#CARGO_ARGS[@]}; _cargo_index++ )); do + case "${CARGO_ARGS[_cargo_index]}" in + --release|-r) + _CARGO_PROFILE_SET=1; export CARGO_PROFILE_DIR="release"; - export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"; ;; - export) - export CARGO_PARAMS="--profile size $CARGO_PARAMS"; - export CARGO_PROFILE_DIR="size"; - export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS"; + --profile) + _CARGO_PROFILE_SET=1; + export CARGO_PROFILE_DIR="${CARGO_ARGS[_cargo_index + 1]}"; + ;; + --profile=*) + _CARGO_PROFILE_SET=1; + export CARGO_PROFILE_DIR="${CARGO_ARGS[_cargo_index]#--profile=}"; + ;; + --target-dir) + export CARGO_TARGET_DIR="${CARGO_ARGS[_cargo_index + 1]:-$CARGO_TARGET_DIR}"; + ;; + --target-dir=*) + export CARGO_TARGET_DIR="${CARGO_ARGS[_cargo_index]#--target-dir=}"; ;; esac -else - # TODO: Extra parameters that could be good to look into: - # -gseparate-dwarf - # -gsplit-dwarf - # -gsource-map - export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS" +done + +if [ "$_CARGO_PROFILE_SET" = "0" ]; then + if [ "$BUILD_MODE" = "profiling" ]; then + CARGO_ARGS=(--profile profiling "${CARGO_ARGS[@]}"); + export CARGO_PROFILE_DIR="profiling"; + elif [ "$BUILD_MODE" = "release" ]; then + case "$RENDER_TARGET" in + frontend) + CARGO_ARGS=(--release "${CARGO_ARGS[@]}"); + export CARGO_PROFILE_DIR="release"; + ;; + export) + CARGO_ARGS=(--profile size "${CARGO_ARGS[@]}"); + export CARGO_PROFILE_DIR="size"; + ;; + esac + fi fi -export FRONTEND_DEST="../frontend/resources/public/js"; -export EXPORT_DEST="../exporter/resources/wasm"; +case "$CARGO_PROFILE_DIR" in + profiling) + # Keep the wasm name section so profilers can resolve Rust frames. + export EMCC_CFLAGS="-O3 -sASSERTIONS=0 --profiling-funcs $EMCC_CFLAGS"; + ;; + release) + export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + size) + export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + *) + # TODO: Extra parameters that could be good to look into: + # -gseparate-dwarf + # -gsplit-dwarf + # -gsource-map + export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"; + ;; +esac -export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js"; -export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js"; +if [ "$CARGO_PROFILE_DIR" = "dev" ]; then + export CARGO_PROFILE_DIR="debug"; +fi + +export RENDER_WASM_BUILD_INFO="${RENDER_WASM_BUILD_INFO:-$CARGO_TARGET_DIR/build-info.json}"; + +export FRONTEND_DEST="${FRONTEND_DEST:-../frontend/resources/public/js}"; +export EXPORT_DEST="${EXPORT_DEST:-../exporter/resources/wasm}"; + +export FRONTEND_SHARED_DEST="${FRONTEND_SHARED_DEST:-../frontend/src/app/render_wasm/api/shared.js}"; +export EXPORT_SHARED_DEST="${EXPORT_SHARED_DEST:-../exporter/src/app/wasm/shared.js}"; function clean { cargo clean; @@ -130,29 +169,39 @@ function setup { } function build { - cargo build $CARGO_PARAMS; + ( + set -o pipefail; + cargo build --message-format=json-render-diagnostics "${CARGO_ARGS[@]}" \ + | node ./scripts/cargo-build.cjs "$RENDER_WASM_BUILD_INFO" "$PWD/Cargo.toml"; + ) } function copy_artifacts { - DEST=$1; - SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR"; + local DEST="$1"; + local SRC; + SRC=$(jq -r '.artifact_dir' -- "$RENDER_WASM_BUILD_INFO") + mkdir -p -- "$DEST"; - mkdir -p $DEST; - - cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js; - cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm; - if [ -f $SRC/render_wasm.wasm.map ]; then - cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; + cp -- "$SRC/render_wasm.js" "$DEST/$BUILD_NAME.js"; + cp -- "$SRC/render_wasm.wasm" "$DEST/$BUILD_NAME.wasm"; + if [ -f "$SRC/render_wasm.wasm.map" ]; then + cp -- "$SRC/render_wasm.wasm.map" "$DEST/$BUILD_NAME.wasm.map"; fi - sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js; + # Rewrite the generated JavaScript so it loads the renamed Wasm file with + # a version query, such as render-wasm.wasm?version=develop. + local WASM_URL="$BUILD_NAME.wasm?version=$VERSION_TAG"; + WASM_URL=${WASM_URL//\\/\\\\}; + WASM_URL=${WASM_URL//&/\\&}; + WASM_URL=${WASM_URL//|/\\|}; + sed -i "s|render_wasm\\.wasm|$WASM_URL|g" "$DEST/$BUILD_NAME.js"; # The worker bundle is a browser concern; the exporter imports the ESM # module directly under Node. if [ "$RENDER_TARGET" = "frontend" ]; then - pnpm exec esbuild $SRC/render_wasm.js \ + pnpm exec esbuild "$SRC/render_wasm.js" \ --log-level=error \ - --outfile=$DEST/worker/render.js \ + "--outfile=$DEST/worker/render.js" \ --platform=neutral \ --format=iife \ --global-name=WasmModule; @@ -160,10 +209,11 @@ function copy_artifacts { } function copy_shared_artifact { - DEST=$1; - SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1); - - cp $SHARED_FILE $DEST; + local DEST="$1"; + local SHARED_FILE; + SHARED_FILE=$(jq -r '.shared_file' -- "$RENDER_WASM_BUILD_INFO") + mkdir -p -- "$(dirname -- "$DEST")"; + cp -- "$SHARED_FILE" "$DEST"; } # Copies whatever the current RENDER_TARGET produced to where that target's @@ -179,4 +229,8 @@ function copy_target_artifacts { copy_shared_artifact "$EXPORT_SHARED_DEST"; ;; esac + + if [ -n "${RENDER_WASM_SHARED_ARTIFACT:-}" ]; then + copy_shared_artifact "$RENDER_WASM_SHARED_ARTIFACT"; + fi } diff --git a/render-wasm/build b/render-wasm/build index 7b73aaf5a7..c4061068f2 100755 --- a/render-wasm/build +++ b/render-wasm/build @@ -23,14 +23,14 @@ case "$_RENDER_TARGET" in ;; esac -set -e +set -e -o pipefail EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh pushd "$_SCRIPT_DIR" > /dev/null export RENDER_TARGET="$_RENDER_TARGET" -. ./_build_env "$_RENDER_TARGET" +. ./_build_env "$_RENDER_TARGET" "${@:2}" . ./_build_lock acquire_build_lock diff --git a/render-wasm/scripts/cargo-build.cjs b/render-wasm/scripts/cargo-build.cjs new file mode 100644 index 0000000000..035703a03c --- /dev/null +++ b/render-wasm/scripts/cargo-build.cjs @@ -0,0 +1,120 @@ +// Usage: cargo build --message-format=json-render-diagnostics ... | +// node scripts/cargo-build.cjs +const fs = require("node:fs"); +const path = require("node:path"); +const readline = require("node:readline"); + +async function main() { + const [infoFile, manifestFile] = process.argv.slice(2); + if (!infoFile || !manifestFile) { + throw new Error( + "Usage: cargo-build.cjs ", + ); + } + const manifest = path.resolve(manifestFile); + // A failed invocation must not leave facts from an earlier build. + fs.rmSync(infoFile, { force: true }); + + const artifacts = []; + const buildScripts = new Map(); + let succeeded = false; + const lines = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + }); + for await (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line); + if (!message || typeof message !== "object" || Array.isArray(message)) { + throw new Error("Invalid Cargo JSON record"); + } + switch (message.reason) { + case "compiler-message": + if (typeof message.message?.rendered === "string") { + process.stderr.write(message.message.rendered); + } + break; + case "compiler-artifact": + if ( + message.target?.name === "render_wasm" && + typeof message.manifest_path === "string" && + path.resolve(message.manifest_path) === manifest + ) { + artifacts.push(message); + } + break; + case "build-script-executed": { + const outDirs = buildScripts.get(message.package_id) || new Set(); + outDirs.add(message.out_dir); + buildScripts.set(message.package_id, outDirs); + break; + } + case "build-finished": + succeeded = message.success === true; + break; + } + } + + if (!succeeded) throw new Error("Cargo build did not finish successfully"); + if (artifacts.length !== 1) { + throw new Error("Expected one root render_wasm compiler-artifact"); + } + const artifact = artifacts[0]; + if ( + typeof artifact.package_id !== "string" || + !artifact.package_id || + !Array.isArray(artifact.features) || + !artifact.features.every((feature) => typeof feature === "string") + ) { + throw new Error("Missing root renderer package_id or actual features"); + } + if ( + !artifact.profile || + typeof artifact.profile !== "object" || + Array.isArray(artifact.profile) || + typeof artifact.profile.opt_level !== "string" || + typeof artifact.profile.debug_assertions !== "boolean" + ) { + throw new Error("Missing or malformed root renderer Cargo profile"); + } + const outDirs = [...(buildScripts.get(artifact.package_id) || [])]; + if (outDirs.length !== 1 || typeof outDirs[0] !== "string" || !outDirs[0]) { + throw new Error( + "Expected one matching root renderer build-script-executed out_dir", + ); + } + const jsFiles = ( + Array.isArray(artifact.filenames) ? artifact.filenames : [] + ).filter( + (file) => + typeof file === "string" && path.basename(file) === "render_wasm.js", + ); + if (jsFiles.length !== 1) + throw new Error("Missing root render_wasm.js artifact"); + + const outDir = path.resolve(outDirs[0]); + const sharedFile = path.join(outDir, "render_wasm_shared.js"); + fs.accessSync(sharedFile, fs.constants.R_OK); + const info = { + package_id: artifact.package_id, + features: artifact.features, + profile: artifact.profile, + out_dir: outDir, + artifact_dir: path.dirname(path.resolve(jsFiles[0])), + shared_file: sharedFile, + }; + fs.mkdirSync(path.dirname(infoFile), { recursive: true }); + const temporary = `${infoFile}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(info, null, 2) + "\n"); + fs.renameSync(temporary, infoFile); + } finally { + fs.rmSync(temporary, { force: true }); + } +} + +main().catch((error) => { + process.stderr.write(`ERROR: ${error.message}\n`); + process.exitCode = 1; + process.stdin.destroy(); +}); diff --git a/render-wasm/scripts/cargo-build.test.cjs b/render-wasm/scripts/cargo-build.test.cjs new file mode 100644 index 0000000000..e757c7cd95 --- /dev/null +++ b/render-wasm/scripts/cargo-build.test.cjs @@ -0,0 +1,349 @@ +const assert = require("node:assert/strict"); +const { spawn, spawnSync } = require("node:child_process"); +const { once } = require("node:events"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const helper = path.join(__dirname, "cargo-build.cjs"); +const finished = { reason: "build-finished", success: true }; + +function fixture(t) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "render-wasm-cargo-build-"), + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const manifest = path.join(root, "Cargo.toml"); + const artifactDir = path.join( + root, + "target with spaces", + "wasm32-unknown-emscripten", + "release", + ); + const outDir = path.join(artifactDir, "build", "render-active", "out"); + const staleOutDir = path.join( + artifactDir, + "build", + "000-render-stale", + "out", + ); + for (const dir of [outDir, staleOutDir]) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(manifest, '[package]\nname = "render"\n'); + fs.writeFileSync( + path.join(artifactDir, "render_wasm.js"), + "export default {};\n", + ); + fs.writeFileSync( + path.join(outDir, "render_wasm_shared.js"), + "active enum table\n", + ); + fs.writeFileSync( + path.join(staleOutDir, "render_wasm_shared.js"), + "stale enum table\n", + ); + + const artifact = { + reason: "compiler-artifact", + package_id: `path+file://${root}#render@0.1.0`, + manifest_path: manifest, + target: { name: "render_wasm", kind: ["bin"] }, + features: ["default", "profile", "profile-macros", "profile-raf"], + profile: { + opt_level: "3", + debuginfo: 0, + debug_assertions: false, + overflow_checks: false, + test: false, + }, + filenames: [path.join(artifactDir, "render_wasm.js")], + fresh: false, + }; + const buildScript = { + reason: "build-script-executed", + package_id: artifact.package_id, + out_dir: outDir, + }; + const otherScript = { + ...buildScript, + package_id: + "registry+https://github.com/rust-lang/crates.io-index#render@0.1.0", + out_dir: staleOutDir, + }; + const otherArtifact = { + ...artifact, + package_id: otherScript.package_id, + manifest_path: path.join(root, "dependency", "Cargo.toml"), + features: ["stats"], + profile: { ...artifact.profile, opt_level: "0", debug_assertions: true }, + }; + return { + root, + manifest, + artifactDir, + outDir, + staleOutDir, + artifact, + buildScript, + otherScript, + otherArtifact, + info: path.join(root, "build-info.json"), + }; +} + +function cargoOutput(records) { + return records.map((record) => JSON.stringify(record)).join("\n") + "\n"; +} + +function run(f, records) { + return spawnSync(process.execPath, [helper, f.info, f.manifest], { + cwd: f.root, + input: cargoOutput(records), + encoding: "utf8", + }); +} + +for (const fresh of [false, true]) { + test(`selects the root package's active enum table and actual features (fresh=${fresh})`, (t) => { + const f = fixture(t); + const result = run(f, [ + f.otherScript, + f.otherArtifact, + { ...f.artifact, fresh }, + f.buildScript, + { + ...f.otherScript, + out_dir: path.join(f.root, "another dependency out"), + }, + finished, + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); + const info = JSON.parse(fs.readFileSync(f.info, "utf8")); + assert.deepEqual(info, { + package_id: f.artifact.package_id, + features: f.artifact.features, + profile: f.artifact.profile, + out_dir: f.outDir, + artifact_dir: f.artifactDir, + shared_file: path.join(f.outDir, "render_wasm_shared.js"), + }); + assert.equal( + fs.readFileSync(info.shared_file, "utf8"), + "active enum table\n", + ); + }); +} + +test("records an empty actual feature set", (t) => { + const f = fixture(t); + const result = run(f, [ + f.buildScript, + { ...f.artifact, features: [] }, + finished, + ]); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(fs.readFileSync(f.info, "utf8")).features, []); +}); + +for (const profile of [ + { + opt_level: "0", + debuginfo: 2, + debug_assertions: true, + overflow_checks: true, + test: false, + }, + { + opt_level: "z", + debuginfo: 0, + debug_assertions: false, + overflow_checks: false, + test: false, + }, +]) { + test(`records the actual Cargo profile without changing fields (opt_level=${profile.opt_level})`, (t) => { + const f = fixture(t); + const result = run(f, [ + f.buildScript, + { ...f.artifact, profile }, + finished, + ]); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + JSON.parse(fs.readFileSync(f.info, "utf8")).profile, + profile, + ); + }); +} + +test( + "streams rendered compiler messages to stderr before Cargo finishes", + { timeout: 10000 }, + async (t) => { + const f = fixture(t); + const child = spawn(process.execPath, [helper, f.info, f.manifest], { + cwd: f.root, + }); + t.after(() => { + if (child.exitCode === null) child.kill(); + }); + child.stdout.resume(); + const closed = once(child, "close"); + const diagnostic = "warning: renderer diagnostic\n"; + let stderr = ""; + const rendered = new Promise((resolve, reject) => { + child.stderr.on("data", (chunk) => { + stderr += chunk; + if (stderr.includes(diagnostic)) resolve(); + }); + child.on("error", reject); + child.on("close", () => { + if (!stderr.includes(diagnostic)) + reject(new Error(stderr || "Missing diagnostic")); + }); + }); + child.stdin.write( + cargoOutput([ + { reason: "compiler-message", message: { rendered: diagnostic } }, + ]), + ); + await rendered; + assert.equal(child.exitCode, null); + assert.equal(fs.existsSync(f.info), false); + child.stdin.end(cargoOutput([f.buildScript, f.artifact, finished])); + const [code] = await closed; + assert.equal(code, 0, stderr); + assert.equal(stderr, diagnostic); + }, +); + +const rejected = [ + [ + "failed Cargo build", + (f) => [f.buildScript, f.artifact, { ...finished, success: false }], + ], + ["missing build-finished record", (f) => [f.buildScript, f.artifact]], + ["missing root artifact", (f) => [f.otherScript, f.otherArtifact, finished]], + [ + "missing matching build script", + (f) => [f.otherScript, f.artifact, finished], + ], + [ + "missing out_dir", + (f) => [{ ...f.buildScript, out_dir: undefined }, f.artifact, finished], + ], + [ + "missing actual features", + (f) => [f.buildScript, { ...f.artifact, features: undefined }, finished], + ], + [ + "invalid actual features", + (f) => [f.buildScript, { ...f.artifact, features: [42] }, finished], + ], + [ + "missing profile", + (f) => [f.buildScript, { ...f.artifact, profile: undefined }, finished], + ], + [ + "null profile", + (f) => [f.buildScript, { ...f.artifact, profile: null }, finished], + ], + [ + "non-object profile", + (f) => [f.buildScript, { ...f.artifact, profile: "release" }, finished], + ], + [ + "array profile", + (f) => [f.buildScript, { ...f.artifact, profile: [] }, finished], + ], + [ + "missing profile opt_level", + (f) => [ + f.buildScript, + { ...f.artifact, profile: { debug_assertions: false } }, + finished, + ], + ], + [ + "invalid profile opt_level", + (f) => [ + f.buildScript, + { ...f.artifact, profile: { opt_level: 3, debug_assertions: false } }, + finished, + ], + ], + [ + "missing profile debug_assertions", + (f) => [ + f.buildScript, + { ...f.artifact, profile: { opt_level: "3" } }, + finished, + ], + ], + [ + "invalid profile debug_assertions", + (f) => [ + f.buildScript, + { ...f.artifact, profile: { opt_level: "3", debug_assertions: "false" } }, + finished, + ], + ], + [ + "missing JS artifact", + (f) => [f.buildScript, { ...f.artifact, filenames: [] }, finished], + ], + [ + "multiple root artifacts", + (f) => [ + f.buildScript, + f.artifact, + { ...f.artifact, features: [] }, + finished, + ], + ], + [ + "ambiguous root out_dir", + (f) => [ + f.buildScript, + { ...f.buildScript, out_dir: f.staleOutDir }, + f.artifact, + finished, + ], + ], +]; + +for (const [name, records] of rejected) { + test(`rejects ${name} and removes stale build facts`, (t) => { + const f = fixture(t); + fs.writeFileSync(f.info, '{"features":["stale"]}\n'); + const result = run(f, records(f)); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /ERROR:/); + assert.equal(fs.existsSync(f.info), false); + }); +} + +test("rejects a missing active enum table even when a stale table exists", (t) => { + const f = fixture(t); + fs.unlinkSync(path.join(f.outDir, "render_wasm_shared.js")); + const result = run(f, [f.otherScript, f.buildScript, f.artifact, finished]); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /render_wasm_shared\.js/); + assert.equal(fs.existsSync(f.info), false); +}); + +test("rejects malformed Cargo JSON and removes stale build facts", (t) => { + const f = fixture(t); + fs.writeFileSync(f.info, '{"features":["stale"]}\n'); + const result = spawnSync(process.execPath, [helper, f.info, f.manifest], { + cwd: f.root, + input: "not Cargo JSON\n", + encoding: "utf8", + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /ERROR:/); + assert.equal(fs.existsSync(f.info), false); +}); diff --git a/render-wasm/test b/render-wasm/test index 5ed665a7d6..3f680ac09a 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -x +set -ex case "$(uname -m)" in aarch64|arm64) _ARCH="aarch64" ;; @@ -14,6 +14,8 @@ pushd "$_SCRIPT_DIR" > /dev/null . ./_build_env frontend +node --test scripts/cargo-build.test.cjs || exit $? + setup cargo test --bin render_wasm -- --show-output diff --git a/render-wasm/watch b/render-wasm/watch index f9ad114bb8..c1177186e2 100755 --- a/render-wasm/watch +++ b/render-wasm/watch @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Usage: ./watch [frontend|export] +# Usage: ./watch [frontend|export] [extra cargo params...] set -e @@ -9,19 +9,26 @@ pushd "$_SCRIPT_DIR" > /dev/null _RENDER_TARGET=${1:-${RENDER_TARGET:-frontend}} export RENDER_TARGET="$_RENDER_TARGET" -. ./_build_env "$_RENDER_TARGET" "${@:2}" +_BUILD_COMMAND=("$_SCRIPT_DIR/build" "$RENDER_TARGET" "${@:2}") # The initial build uses the protected build entry point. Its lock is released # before cargo watch starts waiting for source changes. -"$_SCRIPT_DIR/build" "$RENDER_TARGET" $CARGO_PARAMS +"${_BUILD_COMMAND[@]}" + +# cargo watch runs shell commands; single-quote each original argument for sh. +_WATCH_BUILD_COMMAND="" +for _arg in "${_BUILD_COMMAND[@]}"; do + _arg=${_arg//\'/\'\\\'\'} + _WATCH_BUILD_COMMAND+=" '$_arg'" +done set -x cargo watch \ --why \ + --postpone \ -i "_tmp*" \ - -x "build $CARGO_PARAMS" \ - -s "\"$_SCRIPT_DIR/build\" \"$RENDER_TARGET\" $CARGO_PARAMS" \ + -s "$_WATCH_BUILD_COMMAND" \ -s "echo 'DONE\n'"; popd > /dev/null From 89c6bad8cd1b9982c298be8fa52719bf2ebface2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Tue, 13 Oct 2026 12:56:48 +0200 Subject: [PATCH 084/325] :sparkles: Open files and projects in a new tab with Ctrl/Cmd+click (#12230) Ctrl+click (Cmd+click on macOS) on a dashboard file card, a project title, the "Show all files" button, or a project or Drafts item in the sidebar now opens it in a new browser tab. It reuses the same `::rt/new-window` navigation as the "Open in new tab" file menu option. On file cards the modifier click does not change the selection, and a modifier double click does not navigate the current tab. Files in the trash are not opened. Closes #12228 AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/dashboard/grid.cljs | 30 ++++++++++++++----- .../src/app/main/ui/dashboard/projects.cljs | 8 +++-- .../src/app/main/ui/dashboard/sidebar.cljs | 11 ++++--- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index b46a0c53c1..7673e94573 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -23,6 +23,7 @@ [app.main.refs :as refs] [app.main.render :as render] [app.main.repo :as rp] + [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.color-bullet :as bc] [app.main.ui.dashboard.file-menu :refer [file-menu* file-menu-items*]] @@ -293,22 +294,35 @@ library-view? (= origin :libraries) + on-open-new-tab + (mf/use-fn + (mf/deps file-id can-restore) + (fn [] + (when-not can-restore + (st/emit! (dcm/go-to-workspace :file-id file-id ::rt/new-window true))))) + on-select (mf/use-fn - (mf/deps selected? selected-num) + (mf/deps selected? selected-num on-open-new-tab) (fn [event] - (when (or (not selected?) (> selected-num 1)) - (dom/stop-propagation event) - (let [shift? (kbd/shift? event)] - (when-not shift? - (st/emit! (dd/clear-selected-files))) - (st/emit! (dd/toggle-file-select file)))))) + (if (kbd/mod? event) + (do + (dom/stop-propagation event) + (on-open-new-tab)) + (when (or (not selected?) (> selected-num 1)) + (dom/stop-propagation event) + (let [shift? (kbd/shift? event)] + (when-not shift? + (st/emit! (dd/clear-selected-files))) + (st/emit! (dd/toggle-file-select file))))))) on-navigate (mf/use-fn (mf/deps file-id can-restore) (fn [event] - (when-not can-restore + ;; A Ctrl/Cmd double click has already opened the file in new + ;; tabs through on-select; the current tab stays where it is. + (when-not (or can-restore (kbd/mod? event)) (let [menu-icon (mf/ref-val menu-ref) target (dom/get-target event)] (when-not (dom/child? target menu-icon) diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 2efbbefd21..ad7819e5b4 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -16,6 +16,7 @@ [app.main.data.project :as dpj] [app.main.data.team :as dtm] [app.main.refs :as refs] + [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.mcp-menu :refer [dashboard-mcp-menu*]] [app.main.ui.dashboard.deleted :as deleted] @@ -127,8 +128,9 @@ on-nav (mf/use-fn (mf/deps project-id) - (fn [] - (st/emit! (dcm/go-to-dashboard-files :project-id project-id)))) + (fn [event] + (st/emit! (dcm/go-to-dashboard-files :project-id project-id + ::rt/new-window (kbd/mod? event))))) toggle-pin (mf/use-fn @@ -286,7 +288,7 @@ :tab-index "0" :on-key-down (fn [event] (when (kbd/enter? event) - (on-nav)))} + (on-nav event)))} (tr "dashboard.show-all-files") [:> icon* {:icon-id i/arrow-right}]])]] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 2f37187244..b528a5c788 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -89,8 +89,9 @@ on-click (mf/use-fn (mf/deps project-id) - (fn [] - (st/emit! (dcm/go-to-dashboard-files :project-id project-id)))) + (fn [event] + (st/emit! (dcm/go-to-dashboard-files :project-id project-id + ::rt/new-window (kbd/mod? event))))) on-key-down (mf/use-fn @@ -297,8 +298,10 @@ go-drafts (mf/use-fn (mf/deps team-id default-project-id) - (fn [] - (st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id)))) + (fn [event] + (st/emit! (dcm/go-to-dashboard-files :team-id team-id + :project-id default-project-id + ::rt/new-window (kbd/mod? event))))) go-drafts-with-key (mf/use-fn From a44c8aba883a37db2c976a732cddbe9b7668283b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 13 Oct 2026 16:33:51 +0200 Subject: [PATCH 085/325] :wrench: Remove wasm warnings (#12291) * :wrench: Drop emscripten verbose linker output from debug builds * :wrench: Fix render-wasm warnings from Rust 1.99 --- render-wasm/_build_env | 2 +- render-wasm/src/shapes/modifiers/grid_layout.rs | 5 +---- render-wasm/src/shapes/text.rs | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index ce5e34d1d5..c8d1fdd1e5 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -136,7 +136,7 @@ case "$CARGO_PROFILE_DIR" in # -gseparate-dwarf # -gsplit-dwarf # -gsource-map - export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"; + export EMCC_CFLAGS="-g -sASSERTIONS=1 $EMCC_CFLAGS"; ;; esac diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index a9749b9d8f..e28c6dbb46 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -314,10 +314,7 @@ fn set_flex_multi_span( // Distribute the space between flex tracks in proportion to the division for track in tracks[start..end].iter_mut() { if track.track_type == GridTrackType::Flex { - let new_size = alloc.clamp(track.size, track.max_size); - let aloc = new_size - track.size; - dist -= aloc; - track.size = new_size; + track.size = alloc.clamp(track.size, track.max_size); } } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 14eb84d347..9a9212ddd2 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -5,7 +5,6 @@ use crate::{ utils::Browser, }; -use core::f32; use macros::ToJs; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::{ From 4023642acbe4a18cd762da6ec07012d633f4ad91 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 14 Oct 2026 09:58:09 +0200 Subject: [PATCH 086/325] :globe_with_meridians: Complete Russian translations to 100% (#11956) Complete ru.po from 1460 to 2275 keys (100%), add words.ru.txt catalog, fix two dropped %s placeholders and normalize board/layout/shortcut terms. Documents Russian conventions in the translations memory. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/frontend/translations.md | 15 +- .../scripts/check-translations/words.ru.txt | 352 ++ frontend/translations/ru.po | 3455 ++++++++++++++++- 3 files changed, 3802 insertions(+), 20 deletions(-) create mode 100644 frontend/scripts/check-translations/words.ru.txt diff --git a/.serena/memories/frontend/translations.md b/.serena/memories/frontend/translations.md index 806ac5d990..ab6c4b1bde 100644 --- a/.serena/memories/frontend/translations.md +++ b/.serena/memories/frontend/translations.md @@ -47,7 +47,6 @@ high-coverage support reference, never the base. - Preserve verbatim: `%s`/`%d`, `{var}`/`{{...}}`, markdown `[text](%s)`, HTML tags, `\n` positions, brand names (Penpot), key names (Ctrl/Shift/Alt), technical terms (SVG, CSS, HSV, RGB). ## Catalan (ca) conventions - - Normative IEC/Termcat Catalan. Address the user in VOSALTRES (2nd person plural): "Deseu", "Creeu", "Ja teniu un compte?". Buttons/menus use short imperatives ("Crea", "Mou", "Restaura"). - Established glossary (reuse exactly, do not re-coin): layer=capa, board=tauler, stroke=traç, fill=Emplenat, blur=Difuminat, shadow=Ombra, clipboard=porta-retalls, delete=Elimina, rename=Canvia el nom, shortcut=drecera, grid=graella (keep "grid" where the file already does, e.g. grid-layout editing), plugins/extensions UI=extensions, layout=Disposició, gradient=Degradat, wireframing kept as loanword. - Ela geminada uses the middle dot: Cancel·la, paral·lel, al·lega. @@ -55,6 +54,20 @@ high-coverage support reference, never the base. - Shortcut/action names (`shortcuts.*`) are noun/infinitive labels, not sentences. Error strings are direct, no hedging. - Same English source in different contexts may legitimately differ (verb "Copia" vs noun "Còpia"; "Desactivat" vs "Deshabilitada" agreeing with "drecera"). Normalize only true duplicates. +## Russian (ru) conventions + +- Formal address with capital ВЫ ("Вы уверены…", "Вы собираетесь…"). Buttons/menus use infinitives ("Создать", "Удалить", "Дублировать"), never imperatives. +- Established glossary (reuse exactly, do not re-coin): layer=слой, board=доска (EN `board` is NEVER кадр — normalized in lote1: toolbar/viewer/shape-menu/header-menu/export-frames/undo/num-of-frames), path/draw tool=контур, stroke=обводка, fill=заливка, blur=размытие, shadow=тень, grid=сетка, clipboard=буфер обмена, shortcuts=сочетания клавиш (normalized in lote1: title/show-shortcuts/not-found), plugin=плагин, layout=макет (`labels.layout` fixed in lote1), gradient=градиент, token=токен, set=набор, theme=тема, file=файл, project=проект, team=команда, library=библиотека, component=компонент. +- No external RU style guide applies verbatim (Mozilla ru guide is marketing-only, GNOME glossary unmaintained, Adobe uses монтажная область for artboard but `ru.po` majority already says доска — keep доска for consistency). +- `words.ru.txt` `[function]` DELIBERATELY omits 1-letter words (в/с/к/о/у/а/и) and ending-colliding particles (на/по/не/ли/же/бы/его/их/те/или…): Russian inflects (проект→проекта, запросили=запрос+или), so listing them flags every inflected noun/verb. Never "complete" that list; put tripped valid words in `[ok]`. +- Never abbreviate т. д. / т. е. in `ru.po`: the lone т/д tokens poison the glue detector's frequency map and flag short words (тон→т+он). Write и так далее out. +- Tokens-domain glossary (lote2, reuse): alias=алиас, remap=переназначить, viewport=вьюпорт, set=набор, theme=тема, spread (shadow)=распространение, margins=внешние отступы, paddings=внутренние отступы, gaps=промежутки, type identifiers stay in English (Line Height, Font Size, innerShadow/dropShadow, none/Uppercase/Lowercase/Capitalize, thin/light/regular/bold/Italic). +- Editor-domain glossary (lote3, reuse): variant=вариант, property=свойство, guide=направляющая, board guide=направляющая доски, MCP server=MCP-сервер, size preset=пресет размера, border-radius section=скругление, path handler=маркер, canvas=холст, render=рендер/рендеринг, plugin (never extensión)=плагин. +- Avoid standalone те (those) in phrasing ("те же" → "такие же"): one lone те validates те+мы splits and flags every темы. Same poisoning class as т. д. +- `words.ru.txt` `[function]` also omits пока (dropped in lote4): with standalone за anywhere in the file, пока+за flags показа. Rare-typo detection loss accepted. +- Billing-domain glossary (lote4, reuse): plan names follow shipped `ru.po`, NOT es: Enterprise=Корпоративный, Professional=Профессиональный, Unlimited=Безлимитный, trial=пробный; subscription=подписка, storage=хранилище, seat editors=редакторы, viewer role=наблюдатель, amounts as `175 $/мес.` / `10 ГБ`; access token=токен доступа, MCP key=MCP-ключ (regenerate=перевыпустить), markdown `[text|target:self](%s)` kept verbatim. +- Closing-domain glossary (lote5, reuse): shortcut noun everywhere=сочетание (shortcuts section titles are noun/infinitive labels, never ярлыки); node=узел, segment=сегмент, measurements=размеры; WebGL unavailable flow uses гайд/руководство по неполадкам + рендеринг WebGL выключен/включён; `ru.po` reached 2275/2275 keys (100%) with 0 fuzzy, 0 untranslated per `msgfmt --statistics`. + ## QA before commit - Run `node ./scripts/translations.js check -l ` from `frontend/` (no default locale: pass `-l` explicitly): 0 errors required; review warnings by hand. Word lists live in `frontend/scripts/check-translations/words..txt` (`[elision]` `[function]` `[common]` `[ok]` `[brands]`); new valid words that trip the gate go to `[ok]`; `check --self-test` covers the detector rules. Without a catalog only the universal checks run. `#, fuzzy` entries are skipped (known-pending, owned elsewhere). diff --git a/frontend/scripts/check-translations/words.ru.txt b/frontend/scripts/check-translations/words.ru.txt new file mode 100644 index 0000000000..430deaa40a --- /dev/null +++ b/frontend/scripts/check-translations/words.ru.txt @@ -0,0 +1,352 @@ +# Word catalog for check-translations.js (Russian). +# Comment lines start with #. Blank lines are ignored. +# [function] short function words; a token starting with one of +# these is likely missing a space (ивкладка style). +# [common] ordinary words used to judge the other half of a split. +# [ok] correct words the splitter would flag; never report +# these. Add new valid words here when the gate trips. +# [brands] extra locale-specific brands/terms kept as-is. +# [elision] letters elided with an apostrophe (Russian has none). +# +# NOTE (Russian): Russian inflects (проект -> проекта, файл -> файла), +# so one-letter words (в, с, к, о, у, а, и) and common endings/particles +# (на, по, не, ли, же, бы, его, их, ...) are DELIBERATELY absent from +# [function]: listing them flags every inflected noun as a glued word. +# The gate still catches placeholder/plural/punctuation errors plus +# long content-word glues (review warnings). + +[elision] + +[function] +без +для +как +что +чтобы +чтоб +если +когда +хотя +пусть +даже +лишь +только +тоже +также +уже +ведь +вот +между +через +перед +после +возле +вокруг +вдоль +внутри +вне +кроме +мимо +около +против +среди +сверху +снизу +слева +справа +вы +мы +он +она +оно +они +меня +тебя +себя +нас +вас +это +этот +эта +эти +тот +та +здесь +там +тут +где +куда +откуда +почему +зачем +сколько +какой +какая +какое +какие + +[common] +создать +удалить +удаление +удалён +добавить +сохранить +сохранён +открыть +закрыть +изменить +выбрать +выбрано +показать +скрыть +переместить +перемещён +копировать +вставить +отменить +вернуть +дублировать +обновить +загрузить +загрузка +скачать +найти +искать +поиск +поделиться +пригласить +приглашение +приглашения +покинуть +восстановить +восстановлены +закрепить +открепить +опубликовать +настроить +настройки +отправить +получить +проверить +использовать +перейти +продолжить +начать +выйти +войти +файл +файлы +файлов +файла +проект +проекты +проектов +проекта +команда +команды +участник +участники +участников +библиотека +библиотеки +библиотек +компонент +компоненты +цвет +цвета +цветов +слой +слои +слоёв +доска +доски +досок +кадр +кадры +контур +обводка +заливка +размытие +тень +тени +сетка +сетки +макет +градиент +шрифт +шрифты +шрифтов +текст +текста +изображение +комментарий +комментарии +уведомление +уведомления +пароль +почта +почту +аккаунт +профиль +роль +доступ +ошибка +ошибки +версия +история +экспорт +импорт +тема +темы +набор +наборы +токен +токены +токенов +панель +окно +вкладка +кнопка +поле +список +таблица +строка +столбец +строки +высота +ширина +размер +имя +название +описание +время +дата +право +права +владелец +администратор +редактор +наблюдатель +гость +новый +новая +новые +общий +общая +последний +следующий +предыдущий +выбранное +непрозрачность +выравнивание +выровнять +направляющие +линейки +пиксельный +масштаб +приблизить +отдалить +группировать +разгруппировать +маска +узел +узлы +фигура +фигуры +элемент +элементы +свойства +стиль +эффект +ссылка +ссылку +вектор +растр +код +плагин +вебхук +шаблон +шаблоны +черновик +корзина +папка +область +холст +фон +граница +угол +центр +край +верх +низ +право +лево +горизонталь +вертикаль +сверху +снизу +слева +справа +внутрь +наружу +сплошной +пунктирный +точечный +круг +квадрат +треугольник +стрелка +эллипс +прямоугольник +многоугольник +звезда +кривая +путь + +[ok] +автосохранение +автовысота +автоширина +медиафайл +цветовой +командой +чтобы +чтоб +какой +какая +какое +какие +какого +каких +каком +какую +горизонтально +вертикально +какой-либо +адресата +последний +последняя +последнее +последние +последней +последнего +последнем +последнюю +доступна +автомакета +запросили +верхняя +верхнее +верхние +верхнего +верхней +верхнем +верхнюю +библиотекой +автоверсии +подорганизации +прочитать +просмотреть + +[brands] diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index 72cd5b5d4b..ccf5d11a4c 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -15,6 +15,10 @@ msgstr "" msgid "auth.already-have-account" msgstr "Уже есть аккаунт?" +#: src/app/main/ui/auth/recovery_request.cljs:111, src/app/main/ui/auth/register.cljs:250 +msgid "auth.check-email" +msgstr "Проверьте почту!" + #: src/app/main/ui/auth/recovery.cljs:83 msgid "auth.confirm-password" msgstr "Подтвердите пароль" @@ -75,6 +79,10 @@ msgstr "OpenID" msgid "auth.new-password" msgstr "Введите новый пароль" +#: src/app/main/ui/auth/verify_token.cljs:81 +msgid "auth.notifications.organization-invitation-accepted" +msgstr "Теперь Вы входите в %s" + #: src/app/main/ui/auth/recovery.cljs:52 msgid "auth.notifications.password-changed-successfully" msgstr "Пароль успешно изменён" @@ -152,6 +160,10 @@ msgstr "" msgid "auth.terms-of-service" msgstr "Условия использования" +#: src/app/main/ui/auth/register.cljs:251 +msgid "auth.verification-sent-email" +msgstr "Мы отправили письмо с подтверждением на адрес:" + #: src/app/main/ui/auth/login.cljs:185, src/app/main/ui/auth/recovery_request.cljs:77, src/app/main/ui/auth/register.cljs:182 msgid "auth.work-email" msgstr "Рабочая эл. почта" @@ -276,6 +288,82 @@ msgstr "Добавить как общую библиотеку" msgid "dashboard.change-email" msgstr "Изменить эл. почту" +#: src/app/main/data/nitrate.cljs:602 +msgid "dashboard.change-organization-modal.accept" +msgstr "ПЕРЕМЕСТИТЬ КОМАНДУ" + +#: src/app/main/data/nitrate.cljs:600 +msgid "dashboard.change-organization-modal.choose" +msgstr "Переместить в:" + +#: src/app/main/data/nitrate.cljs:599 +msgid "dashboard.change-organization-modal.description" +msgstr "" +"Проекты и файлы останутся доступны участникам команды. Команда получит " +"настройки новой организации." + +#: src/app/main/data/nitrate.cljs:601 +msgid "dashboard.change-organization-modal.select" +msgstr "Выберите организацию" + +#: src/app/main/data/nitrate.cljs:591, src/app/main/data/nitrate.cljs:598, src/app/main/ui/dashboard/team_form.cljs:159 +msgid "dashboard.change-organization-modal.title" +msgstr "Смена организации команды" + +#: src/app/main/ui/dashboard/check_updates.cljs:334 +msgid "dashboard.check-updates.available-message" +msgstr "" +"Новые выпуски улучшают скорость работы, добавляют возможности и " +"исправления, чтобы Ваша установка не устаревала." + +#: src/app/main/ui/dashboard/check_updates.cljs:309 +msgid "dashboard.check-updates.available-title" +msgstr "ДОСТУПНА НОВАЯ ВЕРСИЯ PENPOT" + +#: src/app/main/ui/dashboard/check_updates.cljs:341 +msgid "dashboard.check-updates.highlights-title" +msgstr "Что нового — главное" + +#: src/app/main/ui/dashboard/check_updates.cljs:318 +msgid "dashboard.check-updates.installed-version" +msgstr "Установленная версия" + +#: src/app/main/ui/dashboard/check_updates.cljs:326 +msgid "dashboard.check-updates.latest-version" +msgstr "Последняя версия" + +#: src/app/main/ui/dashboard/check_updates.cljs:235 +msgid "dashboard.check-updates.try-again" +msgstr "Попробовать снова" + +#: src/app/main/ui/dashboard/check_updates.cljs:230 +msgid "dashboard.check-updates.unable-hint" +msgstr "Проверьте сеть и настройки экземпляра и попробуйте снова." + +#: src/app/main/ui/dashboard/check_updates.cljs:226 +msgid "dashboard.check-updates.unable-message" +msgstr "Этой установке не удалось связаться со службой выпусков Penpot." + +#: src/app/main/ui/dashboard/check_updates.cljs:215 +msgid "dashboard.check-updates.unable-title" +msgstr "НЕ УДАЛОСЬ ПРОВЕРИТЬ ОБНОВЛЕНИЯ" + +#: src/app/main/ui/dashboard/check_updates.cljs:265 +msgid "dashboard.check-updates.uptodate-message" +msgstr "У Вас последняя стабильная версия:" + +#: src/app/main/ui/dashboard/check_updates.cljs:254 +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT ОБНОВЛЁН" + +#: src/app/main/ui/dashboard/check_updates.cljs:371 +msgid "dashboard.check-updates.view-changelog" +msgstr "Открыть полный список изменений" + +#: src/app/main/ui/dashboard/check_updates.cljs:374 +msgid "dashboard.check-updates.view-release-notes" +msgstr "Открыть примечания к выпуску" + #: src/app/main/ui/dashboard/deleted.cljs:322 msgid "dashboard.clear-trash-button" msgstr "Очистить корзину" @@ -442,7 +530,7 @@ msgstr "Скачать файлы Penpot (.penpot) (%s)" #: src/app/main/ui/workspace/main_menu.cljs:745 msgid "dashboard.export-frames" -msgstr "Экспорт кадров в PDF" +msgstr "Экспорт досок в PDF" #: src/app/main/ui/exports/assets.cljs:202 msgid "dashboard.export-frames.title" @@ -486,6 +574,14 @@ msgid_plural "dashboard.file-menu.restore-files-option" msgstr[0] "Восстановить файл" msgstr[1] "Восстановить файлы" +#: src/app/main/ui/dashboard/layout_toggle.cljs:32 +msgid "dashboard.files-layout.grid" +msgstr "Вид сетки" + +#: src/app/main/ui/dashboard/layout_toggle.cljs:28 +msgid "dashboard.files-layout.list" +msgstr "Вид списка" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Шрифт удалён" @@ -530,9 +626,8 @@ msgid "dashboard.fonts.hero-text2" msgstr "" "Вам следует загружать только собственные шрифты, или у которых есть " "лицензия на использование в Penpot. Больше информации в разделе \"Content " -"rights\" в [Условиях использования Penpot](https://penpot.app/terms.html). " -"Также можете прочитать о [лицензировании " -"шрифтов](https://www.typography.com/faq)." +"rights\" в [Условиях использования Penpot](%s). Также можете прочитать о " +"[лицензировании шрифтов](https://www.typography.com/faq)." #: src/app/main/ui/dashboard/fonts.cljs:231 msgid "dashboard.fonts.upload-all" @@ -549,6 +644,14 @@ msgstr "" "использовать [Transfonter](https://transfonter.org/) для создания " "веб-шрифтов и исправления ошибок. " +#: src/app/main/ui/dashboard/organization_team_switch.cljs:240 +msgid "dashboard.go-to-admin-console" +msgstr "Перейти в Admin Console" + +#: src/app/main/ui/dashboard/grid.cljs:252 +msgid "dashboard.grid.last-modified-at" +msgstr "Последнее изменение %s" + #: src/app/main/ui/dashboard/import.cljs:995, src/app/main/ui/dashboard/project_menu.cljs:110 msgid "dashboard.import" msgstr "Импорт файлов Penpot" @@ -557,10 +660,36 @@ msgstr "Импорт файлов Penpot" msgid "dashboard.import.analyze-error" msgstr "Ой! Не вышло импортировать этот файл" +#: src/app/main/ui/dashboard/import.cljs:597 +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 библиотека была автоматически связана по имени." +msgstr[1] "%s библиотек было автоматически связано по имени." + #: src/app/main/ui/dashboard.cljs:264 msgid "dashboard.import.bad-url" msgstr "Импорт не удался. URL-адрес шаблона неверен" +#: src/app/main/ui/dashboard/import.cljs:733 +msgid "dashboard.import.confirm-library-links" +msgstr "Подтвердите связи библиотек" + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "Свяжите выбранные библиотеки" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Имя файла" + +#: src/app/main/ui/dashboard/import.cljs:619 +msgid "dashboard.import.import-error.check-error" +msgstr "Не удалось проверить этот файл." + +#: src/app/main/ui/dashboard/import.cljs:623 +msgid "dashboard.import.import-error.corrupt-file" +msgstr "Похоже, этот файл повреждён." + #: src/app/main/ui/dashboard/import.cljs:603 msgid "dashboard.import.import-error.disclaimer" msgstr "Не все файлы были импортированы" @@ -573,6 +702,10 @@ msgstr "В следующих файлах есть ошибки:" msgid "dashboard.import.import-error.message2" msgstr "Файлы с ошибками загружены не будут." +#: src/app/main/ui/dashboard/import.cljs:626 +msgid "dashboard.import.import-error.unknown-error" +msgstr "Что-то пошло не так при обработке файла." + #: src/app/main/ui/dashboard/import.cljs:593 msgid "dashboard.import.import-message" msgid_plural "dashboard.import.import-message" @@ -583,6 +716,10 @@ msgstr[1] "%s файлов было успешно импортировано." msgid "dashboard.import.import-warning" msgstr "Некоторые файлы содержали неверные объекты, которые были удалены." +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "Следующий файл" + #: src/app/main/ui/dashboard.cljs:265 msgid "dashboard.import.no-perms" msgstr "У вас нет разрешения на импорт в эту команду" @@ -615,6 +752,44 @@ msgstr "Загрузка данных на сервер (%s/%s)" msgid "dashboard.import.progress.upload-media" msgstr "Загрузка файла: %s" +#: src/app/main/ui/dashboard/import.cljs:443 +msgid "dashboard.import.resolve-libraries" +msgstr "" +"Некоторые библиотеки не удалось связать автоматически. Выберите правильную " +"библиотеку для каждой:" + +#: src/app/main/ui/dashboard/import.cljs:565 +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Проверьте связи библиотек перед подтверждением:" + +#: src/app/main/ui/dashboard/import.cljs:459 +msgid "dashboard.import.resolve-libraries.connect-to" +msgstr "связать с" + +#: src/app/main/ui/dashboard/import.cljs:453 +msgid "dashboard.import.resolve-libraries.original-library" +msgstr "исходная библиотека" + +#: src/app/main/ui/dashboard/import.cljs:521 +msgid "dashboard.import.summary.linked" +msgstr "связана автоматически" + +#: src/app/main/ui/dashboard/import.cljs:529 +msgid "dashboard.import.summary.manually-linked" +msgstr "связана вручную" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "Новая" + +#: src/app/main/ui/dashboard/import.cljs:558 +msgid "dashboard.import.summary.no-selection" +msgstr "Библиотека не выбрана" + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "Оригинал" + #: src/app/main/ui/dashboard/team.cljs:881 msgid "dashboard.invitation-modal.delete" msgstr "Вы собираетесь удалить приглашения в:" @@ -631,10 +806,28 @@ msgstr "Удалить приглашения" msgid "dashboard.invitation-modal.title.resend-invitations" msgstr "Повторно отправить приглашения" +#: src/app/main/ui/dashboard/team.cljs:861, src/app/main/ui/dashboard/team.cljs:1202, src/app/main/ui/dashboard/team_form.cljs:156 +msgid "dashboard.invitations.no-permission" +msgstr "" +"У Вас нет разрешения приглашать людей в эту команду, а также изменять или " +"удалять приглашения." + #: src/app/main/ui/dashboard/team.cljs:149, src/app/main/ui/dashboard/team.cljs:860 msgid "dashboard.invite-profile" msgstr "Пригласить людей" +#: src/app/main/ui/dashboard/team.cljs:152 +msgid "dashboard.invite-profile-disabled" +msgstr "У Вас нет разрешения приглашать людей в эту команду" + +#: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266 +msgid "dashboard.leave-organization" +msgstr "Покинуть организацию" + +#: src/app/main/data/nitrate.cljs:259 +msgid "dashboard.leave-organization.toast" +msgstr "Вы больше не входите в организацию %s." + #: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:210, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:216, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:221, src/app/main/ui/dashboard/team.cljs:464 msgid "dashboard.leave-team" msgstr "Покинуть команду" @@ -705,6 +898,32 @@ msgstr "Новый проект" msgid "dashboard.no-matches-for" msgstr "Совпадений для “%s“ не найдено" +#: src/app/main/ui/dashboard/team_form.cljs:158 +msgid "dashboard.no-organization-allows-create-team.message" +msgstr "У Вас нет разрешения добавлять команды ни в одну из Ваших организаций." + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "Организаций пока нет." + +#: src/app/main/ui/dashboard/team_form.cljs:152 +msgid "dashboard.no-permission-create-team.message" +msgstr "" +"Вам нельзя создавать команды в организации %s. Если нужна информация, " +"свяжитесь с владельцем организации." + +#: src/app/main/ui/dashboard/team_form.cljs:154 +msgid "dashboard.no-permission-delete-team.message" +msgstr "" +"Вам нельзя удалять команды, входящие в организацию %s. Если нужна " +"информация, свяжитесь с владельцем организации." + +#: src/app/main/ui/dashboard/team_form.cljs:160 +msgid "dashboard.no-permission-move-team.message" +msgstr "" +"Вам нельзя перемещать команды, входящие в организацию %s. Если нужна " +"информация, свяжитесь с владельцем организации." + #: src/app/main/ui/dashboard/sidebar.cljs:400 msgid "dashboard.no-projects-placeholder" msgstr "Закреплённые проекты будут здесь" @@ -753,6 +972,14 @@ msgstr "Сортировать по роли" msgid "dashboard.order-invitations-by-status" msgstr "Сортировать по статусу" +#: src/app/main/data/dashboard.cljs:703 +msgid "dashboard.organization-deleted" +msgstr "Организация %s удалена." + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:208, src/app/main/ui/dashboard/organization_team_switch.cljs:209 +msgid "dashboard.other-teams" +msgstr "Другие команды" + #: src/app/main/ui/settings/password.cljs:112, src/app/main/ui/settings/password.cljs:126 msgid "dashboard.password-change" msgstr "Изменить пароль" @@ -773,6 +1000,10 @@ msgstr "Теперь вы владелец этой команды." msgid "dashboard.permissions-change.viewer" msgstr "Теперь вы наблюдатель в этой команде." +#: src/app/main/ui/dashboard/file_menu.cljs:38, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:58, src/app/main/ui/dashboard/organization_team_switch.cljs:57, src/app/main/ui/dashboard/projects.cljs:50, src/app/main/ui/dashboard/projects.cljs:364, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/team.cljs:647, src/app/main/ui/dashboard/team.cljs:1257, src/app/main/ui/dashboard/team.cljs:1537, src/app/main/ui/dashboard/team.cljs:1644 +msgid "dashboard.personal-projects" +msgstr "Личные проекты" + #: src/app/main/ui/dashboard/files.cljs:122, src/app/main/ui/dashboard/project_menu.cljs:98, src/app/main/ui/dashboard/projects.cljs:251 msgid "dashboard.pin-unpin" msgstr "Закрепить/Открепить" @@ -863,6 +1094,52 @@ msgstr "Поиск…" msgid "dashboard.searching-for" msgstr "Поиск \"%s\"…" +#: src/app/main/ui/dashboard/organization_team_switch.cljs:180 +msgid "dashboard.section.organizations" +msgstr "Организации" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:249 +msgid "dashboard.section.teams" +msgstr "Команды" + +#: src/app/main/data/nitrate.cljs:517 +msgid "dashboard.select-organization-modal.accept" +msgstr "ДОБАВИТЬ В ОРГАНИЗАЦИЮ" + +#: src/app/main/data/nitrate.cljs:515 +msgid "dashboard.select-organization-modal.choose" +msgstr "Выберите организацию:" + +#: src/app/main/ui/dashboard/team.cljs:1041 +msgid "dashboard.select-organization-modal.external-invitations-warning" +msgstr "" +"«%s» разрешает вступать в команды только своим участникам. Все ожидания " +"приглашений для посторонних будут отозваны." + +#: src/app/main/ui/dashboard/team.cljs:1037 +msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" +msgstr "Ожидающие приглашения для внешних пользователей будут отменены." + +#: src/app/main/data/nitrate.cljs:583 +msgid "dashboard.select-organization-modal.permission-info" +msgstr "" +"Здесь организации, в которые Вы входите и где Вам разрешено перемещать " +"команду." + +#: src/app/main/data/nitrate.cljs:507 +msgid "dashboard.select-organization-modal.permission-info-add" +msgstr "" +"Здесь организации, в которые Вы входите и где Вам разрешено создавать и " +"добавлять команды." + +#: src/app/main/data/nitrate.cljs:516 +msgid "dashboard.select-organization-modal.select" +msgstr "Выберите организацию" + +#: src/app/main/data/nitrate.cljs:514, src/app/main/data/nitrate.cljs:534, src/app/main/ui/dashboard/team_form.cljs:157 +msgid "dashboard.select-organization-modal.title" +msgstr "Добавить команду в организацию" + #: src/app/main/ui/settings/options.cljs:66 msgid "dashboard.select-ui-language" msgstr "Выберите язык интерфейса" @@ -983,6 +1260,11 @@ msgstr "Ваши файлы успешно перемещены" msgid "dashboard.success-move-project" msgstr "Ваш проект перемещён" +#: src/app/main/data/common.cljs:266 +#, backend +msgid "dashboard.team-belong-organization" +msgstr "Эта команда теперь входит в организацию %s" + #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" msgstr "О команде" @@ -991,6 +1273,39 @@ msgstr "О команде" msgid "dashboard.team-members" msgstr "Участники команды" +#: src/app/main/data/common.cljs:267 +#, backend +msgid "dashboard.team-no-longer-belong-organization" +msgstr "Эта команда больше не входит в организацию %s" + +#: src/app/main/ui/dashboard/team.cljs:1676 +msgid "dashboard.team-organization" +msgstr "Организация команды" + +#: src/app/main/ui/dashboard/team.cljs:1710 +msgid "dashboard.team-organization.add" +msgstr "Добавить в организацию" + +#: src/app/main/data/nitrate.cljs:533, src/app/main/data/nitrate.cljs:590 +msgid "dashboard.team-organization.add.no-valid-organizations" +msgstr "У Вас нет разрешения добавлять эту команду ни в одну из Ваших организаций." + +#: src/app/main/ui/dashboard/team.cljs:1699 +msgid "dashboard.team-organization.change" +msgstr "Сменить организацию команды" + +#: src/app/main/ui/dashboard/team.cljs:948 +msgid "dashboard.team-organization.disabled-organization-tooltip" +msgstr "Приглашать можно только людей из Вашей организации." + +#: src/app/main/ui/dashboard/team.cljs:1706 +msgid "dashboard.team-organization.none" +msgstr "Эта команда не входит ни в одну организацию" + +#: src/app/main/ui/dashboard/team.cljs:1702 +msgid "dashboard.team-organization.remove" +msgstr "Убрать команду из организации" + #: src/app/main/ui/dashboard/team.cljs:1729 msgid "dashboard.team-projects" msgstr "Командные проекты" @@ -1037,6 +1352,42 @@ msgstr "Снять библиотеку с публикации" msgid "dashboard.update-settings" msgstr "Обновить настройки" +#: src/app/main/data/dashboard.cljs:671 +#, backend +msgid "dashboard.user-no-longer-belong-organization" +msgstr "Вы больше не входите в организацию %s" + +#: src/app/main/ui/settings/options.cljs:109 +msgid "dashboard.webgl-switch.beta" +msgstr "Бета" + +#: src/app/main/ui/settings/options.cljs:110 +msgid "dashboard.webgl-switch.description" +msgstr "" +"Рендеринг WebGL может ускорить работу, но он в стадии бета и менее " +"стабилен. Возможны расхождения между холстом, экспортом (SVG/PDF) и режимом " +"просмотра." + +#: src/app/main/ui/settings/options.cljs:113 +msgid "dashboard.webgl-switch.disabled" +msgstr "Отключено" + +#: src/app/main/ui/settings/options.cljs:113 +msgid "dashboard.webgl-switch.enabled" +msgstr "Включено" + +#: src/app/main/ui/settings/options.cljs:116 +msgid "dashboard.webgl-switch.feedback" +msgstr "Отправить отзыв" + +#: src/app/main/ui/settings/options.cljs:112 +msgid "dashboard.webgl-switch.status" +msgstr "Статус" + +#: src/app/main/ui/settings/options.cljs:108 +msgid "dashboard.webgl-switch.title" +msgstr "Рендеринг WebGL" + #: src/app/main/ui/dashboard/team.cljs:1404 msgid "dashboard.webhooks.active" msgstr "Активен" @@ -1128,6 +1479,16 @@ msgstr "Совпадений не найдено." msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Открыть список токенов" +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:55, src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs:54, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:118, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:134 +msgid "ds.inputs.token-field.no-active-token-option" +msgstr "Токен {%s} пропал из активных наборов или неверен." + +#: src/app/main/ui/auth/login.cljs:113 +msgid "errors.account-locked" +msgstr "" +"Слишком много неудачных попыток. Ваш аккаунт заблокирован на %s минут. " +"Попробуйте позже." + #: src/app/main/data/auth.cljs:344 msgid "errors.auth-provider-not-allowed" msgstr "Поставщик аутентификации не разрешён для этого профиля" @@ -1156,14 +1517,34 @@ msgstr "Невозможно загрузить медиафайл." msgid "errors.character-limit-exceeded" msgstr "Превышен лимит символов" +#: src/app/main/data/workspace/libraries.cljs:1691 +msgid "errors.circular-library-reference" +msgstr "Не удаётся добавить библиотеку: возникнет циклическая зависимость" + +#: src/app/main/ui/settings/integrations.cljs:93, src/app/util/clipboard.cljs:248 +msgid "errors.clipboard-api-unavailable" +msgstr "" +"Clipboard API недоступен. Откройте Penpot по HTTPS, чтобы разрешить доступ " +"к буферу обмена" + #: src/app/main/data/workspace/clipboard.cljs:514 msgid "errors.clipboard-not-implemented" msgstr "Ваш браузер не поддерживает эту операцию" +#: src/app/util/clipboard.cljs:245 +msgid "errors.clipboard-permission-denied" +msgstr "" +"Доступ к буферу обмена запрещён. Разрешите его в настройках браузера, чтобы " +"вставлять содержимое" + #: src/app/main/errors.cljs:773 msgid "errors.comment-error" msgstr "В комментарии произошла ошибка" +#: src/app/main/errors.cljs:499 +msgid "errors.connection-error" +msgstr "Нет связи с сервером. Проверьте соединение и попробуйте снова." + #: src/app/main/errors.cljs:842 msgid "errors.deprecated" msgstr "" @@ -1226,7 +1607,7 @@ msgstr "Функция «%s» не поддерживается." #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:302, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:234, ../common/src/app/common/files/tokens.cljc:217, ../common/src/app/common/files/tokens.cljc:298, ../common/src/app/common/files/tokens.cljc:339, ../common/src/app/common/schema.cljc:1063 msgid "errors.field-max-length" -msgstr "Должен содержать не более 1 символа." +msgstr "Должен содержать не более %s символов." #: ../common/src/app/common/schema.cljc:1068 msgid "errors.field-min-length" @@ -1247,6 +1628,12 @@ msgstr "" "функциями файла, который вы пытаетесь открыть. Прежде чем файл можно будет " "открыть, необходимо применить миграцию для «%s»." +#: src/app/main/ui/dashboard/fonts.cljs:341 +msgid "errors.font-family-invalid-chars" +msgstr "" +"Имя семейства шрифтов может содержать только буквы, цифры, пробелы, дефисы, " +"подчёркивания и точки." + #: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966 msgid "errors.generic" msgstr "Что-то пошло не так." @@ -1279,6 +1666,16 @@ msgstr "Неверный код восстановления." msgid "errors.invalid-text" msgstr "Неверный текст" +#: src/app/main/ui/static.cljs:88 +msgid "errors.invite-email-mismatch" +msgstr "" +"Это приглашение для другой почты. Выйдите и войдите под приглашённым " +"аккаунтом или попросите владельца команды прислать новое приглашение." + +#: src/app/main/ui/static.cljs:92 +msgid "errors.invite-expired" +msgstr "Срок приглашения истёк. Попросите владельца команды прислать Вам новое." + #: src/app/main/ui/static.cljs:95 msgid "errors.invite-invalid" msgstr "Приглашение недействительно" @@ -1329,6 +1726,18 @@ msgstr "Только создатель версии может ее забло msgid "errors.only-creator-can-unlock" msgstr "Разблокировать версию может только ее создатель" +#: src/app/main/data/nitrate.cljs:209 +msgid "errors.organization-leave.no-valid-teams" +msgstr "Не удалось покинуть организацию. Попробуйте снова." + +#: src/app/main/data/nitrate.cljs:210 +msgid "errors.organization-leave.organization-owner-cannot-leave" +msgstr "Владелец не может покинуть организацию." + +#: src/app/main/ui/auth/verify_token.cljs:125 +msgid "errors.organization-not-found" +msgstr "Такой организации не существует" + #: src/app/main/ui/auth/recovery.cljs:25, src/app/main/ui/settings/password.cljs:73 msgid "errors.password-invalid-confirmation" msgstr "Пароль для подтверждения должен совпадать" @@ -1353,6 +1762,16 @@ msgstr "Ваш адрес электронной почты не доступе msgid "errors.registration-disabled" msgstr "Регистрация сейчас отключена." +#: src/app/main/errors.cljs:538 +msgid "errors.save-failed" +msgstr "" +"Автосохранение не работает из-за ошибки. Сообщите о ней в поддержку и " +"перезагрузите страницу, чтобы продолжить с последней сохранённой версии." + +#: src/app/main/data/persistence.cljs:307 +msgid "errors.save-retrying" +msgstr "Соединение потеряно. Пробуем сохранить Ваши изменения." + #: src/app/main/errors.cljs:764 msgid "errors.svg-parser.invalid-svg" msgstr "SVG недействителен или имеет неправильный формат" @@ -1371,10 +1790,18 @@ msgstr "" msgid "errors.team-leave.member-does-not-exists" msgstr "Участник, которого вы пытаетесь назначить, не существует." +#: src/app/main/data/nitrate.cljs:191 +msgid "errors.team-leave.only-owner-can-delete" +msgstr "Удалить команду может только её владелец." + #: src/app/main/data/nitrate.cljs:194, src/app/main/ui/dashboard/team.cljs:511 msgid "errors.team-leave.owner-cant-leave" msgstr "Нужно переназначить роль владельца перед тем, как покинуть команду." +#: ../common/src/app/common/types/team.cljc:27 +msgid "errors.team-name-invalid-chars" +msgstr "Название команды не может содержать символы: «.», «:» или «/»" + #: src/app/main/ui/workspace/tokens/sets/helpers.cljs:25, src/app/main/ui/workspace/tokens/sets/helpers.cljs:46, ../common/src/app/common/files/tokens.cljc:291 msgid "errors.token-set-already-exists" msgstr "Набор с таким именем уже существует" @@ -1389,6 +1816,132 @@ msgstr "" msgid "errors.token-theme-already-exists" msgstr "Вариант темы с таким же названием уже существует" +#: ../common/src/app/common/files/tokens.cljc:351 +msgid "errors.token-theme-not-existing-sets" +msgstr "Тема ссылается на несуществующие наборы: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:50, ../common/src/app/common/files/tokens.cljc:230 +msgid "errors.tokens.circular-reference" +msgstr "У токена циклическая ссылка" + +#: src/app/main/data/workspace/tokens/errors.cljs:116, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:245 +msgid "errors.tokens.composite-line-height-needs-font-size" +msgstr "" +"Line Height зависит от Font Size. Добавьте Font Size — и значение " +"посчитается." + +#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 +msgid "errors.tokens.empty-field" +msgstr "Поле не может быть пустым" + +#: src/app/main/data/workspace/tokens/errors.cljs:42, ../common/src/app/common/files/tokens.cljc:34 +msgid "errors.tokens.empty-input" +msgstr "Значение токена не может быть пустым" + +#: src/app/main/data/workspace/tokens/errors.cljs:16 +msgid "errors.tokens.error-parse" +msgstr "Ошибка импорта: не удалось разобрать JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 +msgid "errors.tokens.import-error" +msgstr "Ошибка импорта:" + +#: src/app/main/data/workspace/tokens/errors.cljs:54 +msgid "errors.tokens.invalid-color" +msgstr "Неверный цвет: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:108 +msgid "errors.tokens.invalid-font-family-token-value" +msgstr "Неверное значение: только ссылка на токен font-family" + +#: src/app/main/data/workspace/tokens/errors.cljs:104, ../common/src/app/common/files/tokens.cljc:79 +msgid "errors.tokens.invalid-font-weight-token-value" +msgstr "" +"Неверная насыщенность: числа (100–950) или имена (thin, light, regular, " +"bold и так далее), после — «Italic»" + +#: src/app/main/data/workspace/tokens/errors.cljs:24 +msgid "errors.tokens.invalid-json" +msgstr "Ошибка импорта: неверные данные токенов в JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:28 +msgid "errors.tokens.invalid-json-token-name" +msgstr "Ошибка импорта: неверное имя токена в JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:29 +msgid "errors.tokens.invalid-json-token-name-detail" +msgstr "" +"«%s» — неверное имя токена.\n" +"Имена токенов — только буквы и цифры через «.» и не с «$» в начале." + +#: src/app/main/data/workspace/tokens/errors.cljs:120 +msgid "errors.tokens.invalid-shadow-type-token-value" +msgstr "Неверный тип тени: только «innerShadow» или «dropShadow»" + +#: src/app/main/data/workspace/tokens/errors.cljs:96 +msgid "errors.tokens.invalid-text-case-token-value" +msgstr "Неверное значение: только none, Uppercase, Lowercase или Capitalize" + +#: src/app/main/data/workspace/tokens/errors.cljs:100 +msgid "errors.tokens.invalid-text-decoration-token-value" +msgstr "Неверное значение: только none, underline и strike-through" + +#: src/app/main/data/workspace/tokens/errors.cljs:132 +msgid "errors.tokens.invalid-token-value-shadow" +msgstr "Неверное значение: нужна ссылка на составной токен тени." + +#: src/app/main/data/workspace/tokens/errors.cljs:112 +msgid "errors.tokens.invalid-token-value-typography" +msgstr "Неверное значение: нужна ссылка на составной токен типографики." + +#: src/app/main/data/workspace/tokens/errors.cljs:76, src/app/main/data/workspace/tokens/errors.cljs:88, src/app/main/data/workspace/tokens/errors.cljs:92, ../common/src/app/common/files/tokens.cljc:42 +msgid "errors.tokens.invalid-value" +msgstr "Неверное значение токена: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:72 +msgid "errors.tokens.missing-references" +msgstr "Нет ссылок на токены: " + +#: src/app/main/data/workspace/tokens/errors.cljs:68, src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs:126 +msgid "errors.tokens.name-collision" +msgstr "Группа %s совпадает по имени с токеном в другом активном наборе." + +#: src/app/main/data/workspace/tokens/errors.cljs:20 +msgid "errors.tokens.no-token-files-found" +msgstr "В файле нет токенов, наборов и тем." + +#: src/app/main/data/workspace/tokens/errors.cljs:58 +msgid "errors.tokens.number-too-large" +msgstr "Неверное значение токена. Итог слишком велик: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:88, src/app/main/data/workspace/tokens/warnings.cljs:15, ../common/src/app/common/files/tokens.cljc:65 +msgid "errors.tokens.opacity-range" +msgstr "Непрозрачность — от 0 до 100% или от 0 до 1 (например, 50% или 0.5)." + +#: src/app/main/data/workspace/tokens/errors.cljs:46, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:305, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:237, ../common/src/app/common/files/tokens.cljc:235 +msgid "errors.tokens.self-reference" +msgstr "У токена ссылка на себя" + +#: src/app/main/data/workspace/tokens/errors.cljs:124 +msgid "errors.tokens.shadow-blur-range" +msgstr "Размытие тени — не меньше 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:128 +msgid "errors.tokens.shadow-spread-range" +msgstr "Распространение тени — не меньше 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:92, src/app/main/data/workspace/tokens/warnings.cljs:19 +msgid "errors.tokens.stroke-width-range" +msgstr "Толщина обводки — не меньше 0." + +#: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 +msgid "errors.tokens.value-with-percent" +msgstr "Неверное значение: символ % запрещён." + +#: src/app/main/data/workspace/tokens/errors.cljs:80 +msgid "errors.tokens.value-with-units" +msgstr "Неверное значение: единицы запрещены." + #: src/app/main/data/media.cljs:77 msgid "errors.unexpected-error" msgstr "Произошла ошибка." @@ -1409,6 +1962,35 @@ msgstr "Эта версия заблокирована и не может быт msgid "errors.version-not-supported" msgstr "Файл имеет несовместимый номер версии" +#: src/app/main/ui/auth/recovery.cljs:44, src/app/main/ui/auth/register.cljs:125, src/app/main/ui/settings/password.cljs:42 +msgid "errors.weak-password" +msgstr "Пароль не соответствует требованиям" + +#: src/app/main/ui/auth/recovery.cljs:37, src/app/main/ui/auth/register.cljs:119, src/app/main/ui/settings/password.cljs:35 +#, backend +msgid "errors.weak-password.insufficient-digits" +msgstr "Минимум 1 цифра" + +#: src/app/main/ui/auth/recovery.cljs:38, src/app/main/ui/auth/register.cljs:120, src/app/main/ui/settings/password.cljs:36 +#, backend +msgid "errors.weak-password.insufficient-lowercase" +msgstr "Минимум 1 строчная буква" + +#: src/app/main/ui/auth/recovery.cljs:40, src/app/main/ui/auth/register.cljs:122, src/app/main/ui/settings/password.cljs:38 +#, backend +msgid "errors.weak-password.insufficient-special" +msgstr "Минимум 1 спецсимвол" + +#: src/app/main/ui/auth/recovery.cljs:39, src/app/main/ui/auth/register.cljs:121, src/app/main/ui/settings/password.cljs:37 +#, backend +msgid "errors.weak-password.insufficient-uppercase" +msgstr "Минимум 1 заглавная буква" + +#: src/app/main/ui/auth/recovery.cljs:36, src/app/main/ui/auth/register.cljs:118, src/app/main/ui/settings/password.cljs:34 +#, backend +msgid "errors.weak-password.too-short" +msgstr "Минимум 8 символов" + #: src/app/main/ui/static.cljs:380 msgid "errors.webgl-context-lost.desc-message" msgstr "" @@ -1525,10 +2107,60 @@ msgstr "Вопрос" msgid "files-download-modal.title" msgstr "Скачать файлы" +#: src/app/main/ui/exports/files.cljs:183 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "Ресурсы связанных библиотек не попадут в файл." + +#: src/app/main/ui/exports/files.cljs:177 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Считать ресурсы простыми объектами" + +#: src/app/main/ui/exports/files.cljs:181 +msgid "files-export-modal.options.include-libraries.message" +msgstr "Файлы со связанными библиотеками попадут в экспорт и сохранят связи." + +#: src/app/main/ui/exports/files.cljs:175 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Экспорт файла и библиотек" + +#: src/app/main/ui/exports/files.cljs:184 +msgid "files-export-modal.options.link-later.message" +msgstr "" +"При импорте можно будет связать существующие библиотеки с совпадающими " +"именами." + +#: src/app/main/ui/exports/files.cljs:178 +msgid "files-export-modal.options.link-later.title" +msgstr "Связать подходящие библиотеки при импорте" + +#: src/app/main/ui/exports/files.cljs:182 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "Файл экспортируется, а ресурсы библиотек вольются в локальную библиотеку." + +#: src/app/main/ui/exports/files.cljs:176 +msgid "files-export-modal.options.merge-libraries.title" +msgstr "Встроить ресурсы библиотек в файл" + #: src/app/main/ui/settings/password.cljs:45 msgid "generic.error" msgstr "Произошла ошибка" +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:161 +msgid "import-shortcuts.apply" +msgstr "Применить" + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:102 +msgid "import-shortcuts.diff-modal-text" +msgstr "Импорт изменит эти сочетания." + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:98 +msgid "import-shortcuts.diff-modal-title" +msgstr "Импорт сочетаний" + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:148 +msgid "import-shortcuts.no-changes" +msgstr "Различий между текущими и импортированными сочетаниями нет." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Цвет" @@ -1605,10 +2237,18 @@ msgstr "Трекинг" msgid "inspect.attributes.typography.line-height" msgstr "Интерлиньяж" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:594 +msgid "inspect.attributes.typography.mixed-font-family" +msgstr "Разные семейства" + #: src/app/main/ui/inspect/attributes/text.cljs:114 msgid "inspect.attributes.typography.text-decoration.none" msgstr "Нет" +#: src/app/main/ui/inspect/attributes/text.cljs:117 +msgid "inspect.attributes.typography.text-decoration.strikethrough" +msgstr "Зачёркнутый" + #: src/app/main/ui/inspect/attributes/text.cljs:115 msgid "inspect.attributes.typography.text-decoration.underline" msgstr "Подчёркнутый" @@ -1675,6 +2315,10 @@ msgstr "Копировать" msgid "inspect.subtitle.main" msgstr "Основной компонент" +#: src/app/main/ui/inspect/right_sidebar.cljs:176 +msgid "inspect.tabs-switcher-label" +msgstr "Переключить вкладку просмотра" + #: src/app/main/ui/inspect/right_sidebar.cljs:142 msgid "inspect.tabs.code.selected.circle" msgstr "Круг" @@ -1689,7 +2333,7 @@ msgstr "Кривая" #: src/app/main/ui/inspect/right_sidebar.cljs:145 msgid "inspect.tabs.code.selected.frame" -msgstr "Кадр" +msgstr "Доска" #: src/app/main/ui/inspect/right_sidebar.cljs:146 msgid "inspect.tabs.code.selected.group" @@ -1751,6 +2395,222 @@ msgstr "Полученное значение:" msgid "inspect.tabs.styles.variants-panel" msgstr "Вариант свойств" +#: src/app/main/ui/settings/integrations.cljs:616 +msgid "integrations.access-tokens.create" +msgstr "Создать токен доступа" + +#: src/app/main/ui/settings/integrations.cljs:632 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Нажмите «Создать токен доступа»." + +#: src/app/main/ui/settings/integrations.cljs:631 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "Токенов пока нет." + +#: src/app/main/ui/settings/integrations.cljs:606 +msgid "integrations.access-tokens.personal" +msgstr "Личные токены доступа" + +#: src/app/main/ui/settings/integrations.cljs:611 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Личные токены — замена логину с паролем: открывают приложениям внутреннее " +"API Penpot" + +#: src/app/main/ui/settings/integrations.cljs:68 +msgid "integrations.copy-to-clipboard" +msgstr "Скопировать в буфер обмена" + +#: src/app/main/ui/settings/integrations.cljs:253 +msgid "integrations.create-access-token.title" +msgstr "Создать токен доступа" + +#: src/app/main/ui/settings/integrations.cljs:252 +msgid "integrations.create-access-token.title.created" +msgstr "Токен доступа создан" + +#: src/app/main/ui/settings/integrations.cljs:371 +msgid "integrations.delete-token.accept" +msgstr "Удалить токен" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.delete-token.message" +msgstr "Вы уверены, что хотите удалить токен?" + +#: src/app/main/ui/settings/integrations.cljs:369 +msgid "integrations.delete-token.title" +msgstr "Удалить токен" + +#: src/app/main/ui/settings/integrations.cljs:213 +msgid "integrations.expiration-180-days" +msgstr "180 дней" + +#: src/app/main/ui/settings/integrations.cljs:210 +msgid "integrations.expiration-30-days" +msgstr "30 дней" + +#: src/app/main/ui/settings/integrations.cljs:211 +msgid "integrations.expiration-60-days" +msgstr "60 дней" + +#: src/app/main/ui/settings/integrations.cljs:212 +msgid "integrations.expiration-90-days" +msgstr "90 дней" + +#: src/app/main/ui/settings/integrations.cljs:208 +msgid "integrations.expiration-date.label" +msgstr "Срок действия" + +#: src/app/main/ui/settings/integrations.cljs:209 +msgid "integrations.expiration-never" +msgstr "Никогда" + +#: src/app/main/ui/settings/integrations.cljs:393 +msgid "integrations.expired-on" +msgstr "Истёк %s" + +#: src/app/main/ui/settings/integrations.cljs:394 +msgid "integrations.expires-on" +msgstr "Истекает %s" + +#: src/app/main/ui/settings/integrations.cljs:294 +msgid "integrations.generate-mcp-key.title" +msgstr "Создать MCP-ключ" + +#: src/app/main/ui/settings/integrations.cljs:292 +msgid "integrations.generate-mcp-key.title.created" +msgstr "MCP-ключ создан" + +#: src/app/main/ui/settings/integrations.cljs:193 +msgid "integrations.info.mcp-server" +msgstr "MCP-сервер Penpot даёт MCP-клиентам прямой доступ к файлам дизайна." + +#: src/app/main/ui/settings/integrations.cljs:119 +msgid "integrations.mcp-key.will-expire" +msgstr "MCP-ключ истекает %s" + +#: src/app/main/ui/settings/integrations.cljs:122 +msgid "integrations.mcp-key.will-not-expire" +msgstr "MCP-ключ бессрочный" + +#: src/app/main/ui/settings/integrations.cljs:498 +msgid "integrations.mcp-server.description" +msgstr "MCP-сервер Penpot даёт MCP-клиентам прямой доступ к файлам дизайна." + +#: src/app/main/ui/settings/integrations.cljs:581 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "Как настроить MCP-клиенты" + +#: src/app/main/ui/settings/integrations.cljs:568 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "Этот URL сервера нужен MCP-клиенту для связи с MCP-сервером Penpot." + +#: src/app/main/ui/settings/integrations.cljs:548 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Перевыпустить MCP-ключ" + +#: src/app/main/ui/settings/integrations.cljs:541 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "MCP-ключ" + +#: src/app/main/ui/settings/integrations.cljs:549 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "MCP-ключ нужен для настройки клиента" + +#: src/app/main/ui/settings/integrations.cljs:504 +msgid "integrations.mcp-server.status" +msgstr "Статус" + +#: src/app/main/ui/settings/integrations.cljs:524 +msgid "integrations.mcp-server.status.disabled" +msgstr "Отключён" + +#: src/app/main/ui/settings/integrations.cljs:523 +msgid "integrations.mcp-server.status.enabled" +msgstr "Включён" + +#: src/app/main/ui/settings/integrations.cljs:514 +msgid "integrations.mcp-server.status.expired.0" +msgstr "Ключ для связи с MCP-сервером истёк. Соединение не установить." + +#: src/app/main/ui/settings/integrations.cljs:519 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Перевыпустите ключ и обновите настройки клиента." + +#: src/app/main/ui/settings/integrations.cljs:489 +msgid "integrations.mcp-server.title" +msgstr "MCP-сервер" + +#: src/app/main/ui/settings/integrations.cljs:493 +msgid "integrations.mcp-server.title.beta" +msgstr "Бета" + +#: src/app/main/ui/settings/integrations.cljs:200 +msgid "integrations.name.label" +msgstr "Имя" + +#: src/app/main/ui/settings/integrations.cljs:201 +msgid "integrations.name.placeholder" +msgstr "Имя подскажет, для чего токен" + +#: src/app/main/ui/settings/integrations.cljs:392 +msgid "integrations.no-expiration" +msgstr "Без срока" + +#: src/app/main/ui/settings/integrations.cljs:478 +msgid "integrations.notification.success.copied-link" +msgstr "Ссылка скопирована в буфер обмена" + +#: src/app/main/ui/settings/integrations.cljs:241, src/app/main/ui/settings/integrations.cljs:274, src/app/main/ui/settings/integrations.cljs:319 +msgid "integrations.notification.success.created" +msgstr "Токен успешно создан" + +#: src/app/main/ui/settings/integrations.cljs:87 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "MCP-ключ скопирован" + +#: src/app/main/ui/settings/integrations.cljs:444 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "MCP-сервер отключён" + +#: src/app/main/ui/settings/integrations.cljs:443 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "MCP-сервер включён" + +#: src/app/main/ui/settings/integrations.cljs:88 +msgid "integrations.notification.success.token-copied" +msgstr "Токен скопирован" + +#: src/app/main/ui/settings/integrations.cljs:337 +msgid "integrations.regenerate-mcp-key.info" +msgstr "" +"Перевыпуск сразу отзовёт текущий ключ. Приложения на нём перестанут " +"работать." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Перевыпустить MCP-ключ" + +#: src/app/main/ui/settings/integrations.cljs:334 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "MCP-ключ перевыпущен" + +#: src/app/main/ui/settings/integrations.cljs:655 +msgid "integrations.title" +msgstr "Интеграции" + +#: src/app/main/ui/settings/integrations.cljs:108 +msgid "integrations.token.info.non-recuperable" +msgstr "Ключ уникален и невосстановим. Потеряете — создайте новый." + +#: src/app/main/ui/settings/integrations.cljs:120 +msgid "integrations.token.will-expire" +msgstr "Токен истекает %s" + +#: src/app/main/ui/settings/integrations.cljs:123 +msgid "integrations.token.will-not-expire" +msgstr "Токен бессрочный" + #: src/app/main/ui/dashboard/comments.cljs:94 msgid "label.mark-all-as-read" msgstr "Отметить все как прочитанное" @@ -1815,6 +2675,10 @@ msgstr "Bad Gateway" msgid "labels.blur" msgstr "Размытие" +#: src/app/main/ui/inspect/attributes/blur.cljs:31, src/app/main/ui/inspect/styles/style_box.cljs:32, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:262, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:274 +msgid "labels.blur-effects" +msgstr "Эффекты размытия" + #: src/app/main/data/common.cljs:121, src/app/main/data/nitrate.cljs:518, src/app/main/data/nitrate.cljs:603, src/app/main/ui/dashboard/change_owner.cljs:67, src/app/main/ui/dashboard/change_owner.cljs:174, src/app/main/ui/dashboard/import.cljs:657, src/app/main/ui/dashboard/team.cljs:902, src/app/main/ui/dashboard/team.cljs:1411, src/app/main/ui/delete_shared.cljs:36, src/app/main/ui/exports/assets.cljs:163, src/app/main/ui/exports/files.cljs:199, src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:156, src/app/main/ui/settings/integrations.cljs:220, src/app/main/ui/settings/restore_shortcuts_modal.cljs:165, src/app/main/ui/shortcuts.cljs:651, src/app/main/ui/viewer/share_link.cljs:203, src/app/main/ui/workspace/sidebar/assets/groups.cljs:178, src/app/main/ui/workspace/tokens/export/modal.cljs:43, src/app/main/ui/workspace/tokens/import/modal.cljs:268, src/app/main/ui/workspace/tokens/import_from_library.cljs:88, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:360, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:73, src/app/main/ui/workspace/tokens/settings/menu.cljs:104, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:243 msgid "labels.cancel" msgstr "Отмена" @@ -1823,6 +2687,14 @@ msgstr "Отмена" msgid "labels.canva" msgstr "Canva" +#: src/app/main/ui/dashboard/sidebar.cljs:553 +msgid "labels.check-for-updates" +msgstr "Проверить обновления" + +#: src/app/main/ui/dashboard/sidebar.cljs:552 +msgid "labels.checking-for-updates" +msgstr "Проверка обновлений…" + #: src/app/main/data/common.cljs:98, src/app/main/ui/alert.cljs:63, src/app/main/ui/confirm.cljs:78, src/app/main/ui/dashboard/check_updates.cljs:217, src/app/main/ui/dashboard/check_updates.cljs:256, src/app/main/ui/dashboard/check_updates.cljs:272, src/app/main/ui/dashboard/check_updates.cljs:311, src/app/main/ui/dashboard/comments.cljs:101, src/app/main/ui/dashboard/import.cljs:997, src/app/main/ui/dashboard/projects.cljs:97, src/app/main/ui/ds/layout/modal.cljs:96, src/app/main/ui/ds/product/panel_title.cljs:32, src/app/main/ui/exports/files.cljs:142, src/app/main/ui/exports/files.cljs:225, src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:82, src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:93, src/app/main/ui/settings/integrations.cljs:128, src/app/main/ui/settings/integrations.cljs:247, src/app/main/ui/settings/integrations.cljs:287, src/app/main/ui/settings/integrations.cljs:329, src/app/main/ui/settings/restore_shortcuts_modal.cljs:85, src/app/main/ui/settings/subscription.cljs:407, src/app/main/ui/static.cljs:159, src/app/main/ui/static.cljs:229, src/app/main/ui/viewer/login.cljs:91, src/app/main/ui/viewer/share_link.cljs:174, src/app/main/ui/workspace/components_debugger.cljs:1102, src/app/main/ui/workspace/libraries.cljs:1155, src/app/main/ui/workspace/sidebar/layers.cljs:595, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:825, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:846, src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:43, src/app/main/ui/workspace/sidebar.cljs:265, src/app/main/ui/workspace/tokens/export.cljs:25, src/app/main/ui/workspace/tokens/import.cljs:19, src/app/main/ui/workspace/tokens/import_from_library.cljs:63, src/app/main/ui/workspace/tokens/management/forms/modals.cljs:99, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:114, src/app/main/ui/workspace/tokens/remapping_modal.cljs:93, src/app/main/ui/workspace/tokens/settings/menu.cljs:77, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:62, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:145, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:476, src/app/main/ui/workspace/webgl_unavailable_modal.cljs:56 msgid "labels.close" msgstr "Закрыть" @@ -1907,10 +2779,18 @@ msgstr "Создать новую команду" msgid "labels.create-team.placeholder" msgstr "Введите название новой команды" +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:111, src/app/main/ui/settings/restore_shortcuts_modal.cljs:102 +msgid "labels.current" +msgstr "Текущее" + #: src/app/main/ui/settings/sidebar.cljs:86 msgid "labels.dashboard" msgstr "Панель управления" +#: src/app/main/ui/settings/restore_shortcuts_modal.cljs:104 +msgid "labels.default" +msgstr "По умолчанию" + #: src/app/main/ui/dashboard/file_menu.cljs:335, src/app/main/ui/dashboard/fonts.cljs:286, src/app/main/ui/dashboard/fonts.cljs:363, src/app/main/ui/dashboard/fonts.cljs:397, src/app/main/ui/dashboard/import.cljs:332, src/app/main/ui/dashboard/project_menu.cljs:116, src/app/main/ui/dashboard/team.cljs:1445, src/app/main/ui/settings/integrations.cljs:376, src/app/main/ui/settings/profile.cljs:117, src/app/main/ui/settings/profile.cljs:128, src/app/main/ui/settings/profile.cljs:129, src/app/main/ui/workspace/sidebar/assets/common.cljs:231, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:222, src/app/main/ui/workspace/sidebar/versions.cljs:257, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:353, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:111, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:394 msgid "labels.delete" msgstr "Удалить" @@ -1931,6 +2811,14 @@ msgstr "Удалить приглашение" msgid "labels.delete-multi-files" msgstr "Удалить файлы (%s)" +#: src/app/main/ui/settings/profile.cljs:116 +msgid "labels.delete-profile-photo.message" +msgstr "Вы уверены, что хотите удалить фото профиля?" + +#: src/app/main/ui/settings/profile.cljs:115 +msgid "labels.delete-profile-photo.title" +msgstr "Удалить фото профиля" + #: src/app/main/ui/dashboard/deleted.cljs:219 msgid "labels.deleted" msgstr "Удаленные" @@ -1947,6 +2835,14 @@ msgstr "Отказаться" msgid "labels.download" msgstr "Скачать %s" +#: src/app/main/ui/dashboard/fonts.cljs:283 +msgid "labels.download-simple" +msgstr "Скачать" + +#: src/app/main/ui/exports/files.cljs:217 +msgid "labels.downloading-file" +msgstr "Скачивание файла…" + #: src/app/main/ui/dashboard/file_menu.cljs:28, src/app/main/ui/dashboard/files.cljs:86, src/app/main/ui/dashboard/files.cljs:207, src/app/main/ui/dashboard/projects.cljs:225, src/app/main/ui/dashboard/projects.cljs:228, src/app/main/ui/dashboard/sidebar.cljs:362 msgid "labels.drafts" msgstr "Черновики" @@ -1971,6 +2867,10 @@ msgstr "Пусто" msgid "labels.error" msgstr "Ошибка" +#: src/app/main/data/workspace/versions.cljs:314 +msgid "labels.exit" +msgstr "Выйти" + #: src/app/main/ui/dashboard/team.cljs:782 msgid "labels.expired-invitation" msgstr "Истекло" @@ -1995,6 +2895,10 @@ msgstr "Figma" msgid "labels.fill" msgstr "Заливка" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1284 +msgid "labels.flex-layout" +msgstr "Flex-макет" + #: src/app/main/ui/dashboard/fonts.cljs:472 msgid "labels.font-family" msgstr "Семейство шрифтов" @@ -2023,6 +2927,10 @@ msgstr "Назад" msgid "labels.graphic-design" msgstr "Графический дизайн" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1288 +msgid "labels.grid-layout" +msgstr "Grid-макет" + #: src/app/main/ui/dashboard/sidebar.cljs:430, src/app/main/ui/workspace/main_menu.cljs:156, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1353, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1381, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1570 msgid "labels.help-center" msgstr "Помощь" @@ -2055,6 +2963,10 @@ msgstr "Информация" msgid "labels.installed-fonts" msgstr "Установленные шрифты" +#: src/app/main/ui/settings/sidebar.cljs:134 +msgid "labels.integrations" +msgstr "Интеграции" + #: src/app/main/ui/static.cljs:423 msgid "labels.internal-error.desc-message-first" msgstr "Что-то пошло не так." @@ -2083,7 +2995,7 @@ msgstr "Язык" #: src/app/main/ui/inspect/styles/style_box.cljs:35, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1298 msgid "labels.layout" -msgstr "Слой" +msgstr "Макет" #: src/app/main/ui/dashboard/sidebar.cljs:436 msgid "labels.learning-center" @@ -2137,6 +3049,26 @@ msgstr "Новый пароль" msgid "labels.next" msgstr "Далее" +#: src/app/main/ui/static.cljs:362 +msgid "labels.nitrate-not-configured.desc-message" +msgstr "Этому экземпляру нужен шаг настройки из последней версии." + +#: src/app/main/ui/static.cljs:365 +msgid "labels.nitrate-not-configured.learn-more" +msgstr "Загляните в наше" + +#: src/app/main/ui/static.cljs:360 +msgid "labels.nitrate-not-configured.main-message" +msgstr "Penpot пока не может запуститься." + +#: src/app/main/ui/static.cljs:370 +msgid "labels.nitrate-not-configured.technical-guide" +msgstr "техническое руководство" + +#: src/app/main/ui/static.cljs:350 +msgid "labels.nitrate-unavailable.main-message" +msgstr "Penpot временно недоступен из-за системной ошибки. Попробуйте чуть позже." + #: src/app/main/ui/dashboard/comments.cljs:120, src/app/main/ui/workspace/comments.cljs:164 msgid "labels.no-comments-available" msgstr "" @@ -2151,6 +3083,10 @@ msgstr "Нет ожидающих приглашений." msgid "labels.no-invitations-gather-people" msgstr "Объединяйтесь и творите вместе." +#: src/app/main/ui/components/select.cljs:256 +msgid "labels.no-matches" +msgstr "Совпадений нет" + #: src/app/main/ui/static.cljs:320 msgid "labels.not-found.main-message" msgstr "Ой!" @@ -2168,8 +3104,8 @@ msgstr[1] "Файлы (%s)" #: src/app/main/ui/viewer/thumbnails.cljs:82 msgid "labels.num-of-frames" msgid_plural "labels.num-of-frames" -msgstr[0] "Кадр" -msgstr[1] "Кадры (%s)" +msgstr[0] "1 доска" +msgstr[1] "%s досок" #: src/app/main/ui/dashboard/team.cljs:1734 msgid "labels.num-of-projects" @@ -2181,6 +3117,10 @@ msgstr[1] "Проекты (%s)" msgid "labels.old-password" msgstr "Старый пароль" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:994 +msgid "labels.open" +msgstr "Открыть" + #: src/app/main/ui/comments.cljs:961, src/app/main/ui/comments.cljs:1026, src/app/main/ui/settings/integrations.cljs:400, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:398, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:590, src/app/main/ui/workspace/top_toolbar.cljs:92 msgid "labels.options" msgstr "Параметры" @@ -2393,10 +3333,28 @@ msgstr "Только мои" msgid "labels.sketch" msgstr "Sketch" +#: src/app/main/ui/dashboard/import.cljs:703 +msgid "labels.skip" +msgstr "Пропустить" + #: src/app/main/ui/dashboard/sidebar.cljs:367 msgid "labels.sources" msgstr "Источники" +#: src/app/main/ui/static.cljs:554 +msgid "labels.sso-error.desc-message" +msgstr "" +"Вход через поставщика удостоверений Вашей организации не завершился. " +"Поставщик может быть недоступен, или Вашего аккаунта ещё нет в его каталоге." + +#: src/app/main/ui/static.cljs:555 +msgid "labels.sso-error.retry" +msgstr "Попробовать снова" + +#: src/app/main/ui/static.cljs:553 +msgid "labels.sso-error.title" +msgstr "Не удалось войти в %s" + #: src/app/main/ui/onboarding/questions.cljs:53 msgid "labels.start" msgstr "Начать" @@ -2425,6 +3383,10 @@ msgstr "SVG" msgid "labels.switch" msgstr "Переключить" +#: src/app/main/ui/dashboard/organization_team_switch.cljs:562 +msgid "labels.team-management" +msgstr "Управление командой" + #: src/app/main/ui/inspect/styles/style_box.cljs:30 msgid "labels.text" msgstr "Текст" @@ -2473,6 +3435,10 @@ msgstr "Загрузить произвольные шрифты" msgid "labels.uploading" msgstr "Загрузка…" +#: src/app/main/ui/dashboard/import.cljs:648 +msgid "labels.uploading-file" +msgstr "Загрузка файла…" + #: src/app/main/ui/inspect/right_sidebar.cljs:65, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1034 msgid "labels.variant" msgstr "Вариант" @@ -2493,6 +3459,12 @@ msgstr "Наблюдатель" msgid "labels.visibility" msgstr "Видимость" +#: src/app/main/data/workspace/tokens/import_export.cljs:62 +msgid "labels.warning-count" +msgid_plural "labels.warning-count" +msgstr[0] "%s предупреждение" +msgstr[1] "%s предупреждения" + #: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:191, src/app/main/ui/dashboard/team.cljs:128, src/app/main/ui/dashboard/team.cljs:138, src/app/main/ui/dashboard/team.cljs:1422 msgid "labels.webhooks" msgstr "Вебхуки" @@ -2509,6 +3481,10 @@ msgstr "(вы)" msgid "labels.your-account" msgstr "Ваш аккаунт" +#: src/app/main/ui/ds/product/loader.cljs:21 +msgid "loader.tips.01.message" +msgstr "Держите дизайны цельными: так их легко обновлять во всех проектах." + #: src/app/main/ui/ds/product/loader.cljs:20 msgid "loader.tips.01.title" msgstr "Переиспользуемые компоненты" @@ -2521,6 +3497,14 @@ msgstr "Работайте с командой в реальном времен msgid "loader.tips.02.title" msgstr "Совместная работа в реальном времени" +#: src/app/main/ui/ds/product/loader.cljs:25 +msgid "loader.tips.03.message" +msgstr "Проектируйте гибко с привычными настройками раскладки в духе CSS." + +#: src/app/main/ui/ds/product/loader.cljs:24 +msgid "loader.tips.03.title" +msgstr "Макеты как в CSS" + #: src/app/main/ui/ds/product/loader.cljs:27 msgid "loader.tips.04.message" msgstr "Получите CSS и SVG код напрямую из ваших макетов." @@ -2529,6 +3513,14 @@ msgstr "Получите CSS и SVG код напрямую из ваших ма msgid "loader.tips.04.title" msgstr "Экспорт в код" +#: src/app/main/ui/ds/product/loader.cljs:29 +msgid "loader.tips.05.message" +msgstr "Делитесь ресурсами и стилями, чтобы держать единство." + +#: src/app/main/ui/ds/product/loader.cljs:28 +msgid "loader.tips.05.title" +msgstr "Библиотеки дизайна" + #: src/app/main/ui/ds/product/loader.cljs:31 msgid "loader.tips.06.message" msgstr "Вдохните жизнь в ваши идеи с помощью анимаций и переходов." @@ -2537,6 +3529,20 @@ msgstr "Вдохните жизнь в ваши идеи с помощью ан msgid "loader.tips.06.title" msgstr "Интерактивные прототипы" +#: src/app/main/ui/ds/product/loader.cljs:33 +msgid "loader.tips.07.message" +msgstr "Penpot строит на SVG и CSS, и разработка идёт без швов." + +#: src/app/main/ui/ds/product/loader.cljs:32 +msgid "loader.tips.07.title" +msgstr "Веб-стандарты" + +#: src/app/main/ui/ds/product/loader.cljs:35 +msgid "loader.tips.08.message" +msgstr "" +"Ускорьте работу удобными сочетаниями клавиш, например Shift + A для " +"автомакета." + #: src/app/main/ui/ds/product/loader.cljs:34 msgid "loader.tips.08.title" msgstr "Горячие клавиши" @@ -2553,6 +3559,10 @@ msgstr "Темный & светлый режим" msgid "loader.tips.10.message" msgstr "Дополните Penpot плагинами от сообщества для расширения функционала." +#: src/app/main/ui/ds/product/loader.cljs:38 +msgid "loader.tips.10.title" +msgstr "Поддержка плагинов" + #: src/app/main/ui/workspace/colorpicker.cljs:494 msgid "media.choose-image" msgstr "Выбрать изображение" @@ -2602,6 +3612,20 @@ msgstr "" msgid "modals.add-shared-confirm.message" msgstr "Добавить \"%s\" как общую библиотеку" +#: src/app/main/data/nitrate.cljs:320 +msgid "modals.before-leave-organization.message" +msgstr "" +"В некоторых командах Вы единственный участник. Эти команды удалятся вместе " +"с проектами и файлами." + +#: src/app/main/data/nitrate.cljs:319, src/app/main/ui/dashboard/change_owner.cljs:151 +msgid "modals.before-leave-organization.title" +msgstr "ПЕРЕД УХОДОМ ИЗ ОРГАНИЗАЦИИ" + +#: src/app/main/data/nitrate.cljs:323 +msgid "modals.before-leave-organization.warning" +msgstr "Все команды, где Вы единственный участник, будут удалены." + #: src/app/main/ui/workspace/nudge.cljs:59 msgid "modals.big-nudge" msgstr "Большой сдвиг" @@ -2656,10 +3680,38 @@ msgstr "Да, удалить мой аккаунт" msgid "modals.delete-account.info" msgstr "Удалив аккаунт, вы потеряете все свои текущие проекты и архивы." +#: src/app/main/ui/settings/delete_account.cljs:73 +msgid "modals.delete-account.info.with-organizations" +msgstr "" +"Действие необратимо. Ваш аккаунт, подписка и организации удалятся сразу. Вы " +"также потеряете доступ к текущим проектам и файлам." + +#: src/app/main/ui/settings/delete_account.cljs:83 +msgid "modals.delete-account.owned-organizations.list-title" +msgstr "Организации, которые будут удалены" + +#: src/app/main/ui/settings/delete_account.cljs:98 +msgid "modals.delete-account.owned-organizations.members-count" +msgstr "Участников: %s" + +#: src/app/main/ui/settings/delete_account.cljs:96 +msgid "modals.delete-account.owned-organizations.teams-count" +msgstr "Команд: %s" + #: src/app/main/ui/settings/delete_account.cljs:64 msgid "modals.delete-account.title" msgstr "Вы уверены, что хотите удалить аккаунт?" +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:229 +msgid "modals.delete-asset-group.message" +msgid_plural "modals.delete-asset-group.message" +msgstr[0] "Вы уверены, что хотите удалить этот ресурс?" +msgstr[1] "Вы уверены, что хотите удалить %s ресурсов?" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:228 +msgid "modals.delete-asset-group.title" +msgstr "Удалить группу" + #: src/app/main/ui/comments.cljs:941 msgid "modals.delete-comment-thread.accept" msgstr "Удалить переписку" @@ -2726,6 +3778,10 @@ msgstr "" msgid "modals.delete-font.title" msgstr "Удаление шрифта" +#: src/app/main/data/team.cljs:159 +msgid "modals.delete-organization-team-confirm.message" +msgstr "Вы уверены, что хотите удалить команду из организации %s?" + #: src/app/main/ui/workspace/context_menu.cljs:808, src/app/main/ui/workspace/sidebar/sitemap.cljs:125 msgid "modals.delete-page.body" msgstr "Вы уверены, что хотите удалить эту страницу?" @@ -2734,6 +3790,14 @@ msgstr "Вы уверены, что хотите удалить эту стра msgid "modals.delete-page.title" msgstr "Удаление страницы" +#: src/app/main/ui/workspace/context_menu.cljs:814 +msgid "modals.delete-pages.body" +msgstr "Вы уверены, что хотите удалить выбранные страницы?" + +#: src/app/main/ui/workspace/context_menu.cljs:813 +msgid "modals.delete-pages.title" +msgstr "Удалить страницы" + #: src/app/main/ui/dashboard/project_menu.cljs:84 msgid "modals.delete-project-confirm.accept" msgstr "Удалить проект" @@ -2832,6 +3896,20 @@ msgstr "Изменить вебхук" msgid "modals.edit-webhook.title" msgstr "Изменить вебхук" +#: src/app/main/ui/workspace/tokens/import_from_library.cljs:75 +msgid "modals.import-library-tokens.description" +msgstr "" +"В библиотеке есть токены и темы, которые, вероятно, используют её " +"компоненты." + +#: src/app/main/ui/workspace/tokens/import_from_library.cljs:92 +msgid "modals.import-library-tokens.import" +msgstr "Импорт токенов" + +#: src/app/main/ui/workspace/tokens/import_from_library.cljs:72 +msgid "modals.import-library-tokens.title" +msgstr "Импортировать токены из библиотеки?" + #: src/app/main/ui/dashboard/team.cljs:308 msgid "modals.invite-member-confirm.accept" msgstr "Отправить приглашение" @@ -2846,6 +3924,47 @@ msgstr "" "Некоторые электронные письма отправлены от текущих членов команды. Их " "приглашения не будут отправлены." +#: src/app/main/data/team.cljs:649 +msgid "modals.invite-restricted-members.all-blocked" +msgstr "" +"Приглашать можно только действующих участников организации. Никто из Вашего " +"списка не входит в неё, поэтому приглашения не отправлены." + +#: src/app/main/data/team.cljs:648 +msgid "modals.invite-restricted-members.all-blocked-title" +msgstr "Приглашения не отправлены" + +#: src/app/main/data/team.cljs:192 +msgid "modals.invite-restricted-members.all-organization-members-in-team" +msgstr "" +"В эту команду можно приглашать только участников организации «%s», а они " +"уже все в команде. Если нужна информация, свяжитесь с владельцем " +"организации." + +#: src/app/main/ui/dashboard/team.cljs:340 +msgid "modals.invite-restricted-members.blocked-addresses" +msgstr "Адреса, которым приглашение не придёт" + +#: src/app/main/ui/dashboard/team.cljs:357 +msgid "modals.invite-restricted-members.cancel" +msgstr "Отмена" + +#: src/app/main/ui/dashboard/team.cljs:330 +msgid "modals.invite-restricted-members.description" +msgstr "Приглашать можно только действующих участников организации." + +#: src/app/main/ui/dashboard/team.cljs:365 +msgid "modals.invite-restricted-members.send" +msgstr "Отправить приглашения" + +#: src/app/main/ui/dashboard/team.cljs:325 +msgid "modals.invite-restricted-members.title" +msgstr "Часть приглашений нельзя отправить" + +#: src/app/main/ui/dashboard/team.cljs:331 +msgid "modals.invite-restricted-members.warning" +msgstr "Часть людей из Вашего списка приглашений не получит." + #: src/app/main/ui/dashboard/team.cljs:278 msgid "modals.invite-team-member.text" msgstr "" @@ -2902,6 +4021,34 @@ msgstr "Вы уверены, что хотите покинуть эту ком msgid "modals.leave-confirm.title" msgstr "Выход из команды" +#: src/app/main/ui/dashboard/change_owner.cljs:158 +msgid "modals.leave-organization-and-reassign.hint" +msgstr "Вы владеете командами организации. Назначьте владельцем другого участника." + +#: src/app/main/ui/dashboard/change_owner.cljs:161 +msgid "modals.leave-organization-and-reassign.hint-delete" +msgstr "" +"В некоторых командах Вы единственный участник. Эти команды удалятся вместе " +"с проектами и файлами." + +#: src/app/main/ui/dashboard/change_owner.cljs:163 +msgid "modals.leave-organization-and-reassign.hint-promote" +msgstr "" +"Кроме того, Вы владеете командами организации. Назначьте владельцем другого " +"участника." + +#: src/app/main/data/nitrate.cljs:321, src/app/main/data/nitrate.cljs:330 +msgid "modals.leave-organization-confirm.accept" +msgstr "Покинуть организацию" + +#: src/app/main/data/nitrate.cljs:329 +msgid "modals.leave-organization-confirm.message" +msgstr "Вы навсегда потеряете доступ ко всем командам, проектам и файлам внутри." + +#: src/app/main/data/nitrate.cljs:328 +msgid "modals.leave-organization-confirm.title" +msgstr "Покинуть организацию %s?" + #: src/app/main/ui/delete_shared.cljs:54 msgid "modals.move-shared-confirm.accept" msgid_plural "modals.move-shared-confirm.accept" @@ -2956,6 +4103,24 @@ msgstr "Ваша библиотека пуста. Вы уверены, что х msgid "modals.publish-empty-library.title" msgstr "Опубликовать пустую библиотеку" +#: src/app/main/data/nitrate.cljs:377 +msgid "modals.remove-team-organization.accept" +msgstr "Убрать из организации" + +#: src/app/main/data/nitrate.cljs:375 +msgid "modals.remove-team-organization.info" +msgstr "" +"Проекты и файлы останутся доступны участникам команды, но настройки " +"организации больше не применятся." + +#: src/app/main/data/nitrate.cljs:374 +msgid "modals.remove-team-organization.text" +msgstr "Вы уверены, что хотите убрать команду «%s» из организации «%s»?" + +#: src/app/main/data/nitrate.cljs:373 +msgid "modals.remove-team-organization.title" +msgstr "УБРАТЬ КОМАНДУ ИЗ ОРГАНИЗАЦИИ" + #: src/app/main/ui/workspace/nudge.cljs:52 msgid "modals.small-nudge" msgstr "Небольшое подталкивание" @@ -2996,6 +4161,194 @@ msgstr "" msgid "modals.update-remote-component.message" msgstr "Обновить компонент в общей библиотеке" +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:58 +msgid "nitrate.activation-code.expired-error" +msgstr "Срок кода истёк." + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:60 +msgid "nitrate.activation-code.invalid-error" +msgstr "Неверный код." + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:59 +msgid "nitrate.activation-code.used-error" +msgstr "Код уже использован." + +#: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:58 +msgid "nitrate.activation-success.active-until" +msgstr "Тариф активен до %s." + +#: src/app/main/ui/dashboard/subscription.cljs:211, src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:69 +msgid "nitrate.activation-success.create-organization" +msgstr "Создать организацию" + +#: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:64 +msgid "nitrate.activation-success.enjoy" +msgstr "Наслаждайтесь тарифом!" + +#: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:61 +msgid "nitrate.activation-success.manage-info" +msgstr "Подпиской управляйте когда угодно — страница «Подписка» в данных аккаунта." + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:121 +msgid "nitrate.code-activation.footer-after" +msgstr "Отправьте файл на" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:126 +msgid "nitrate.code-activation.footer-before" +msgstr "— и вышлем Вам код." + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:119 +msgid "nitrate.code-activation.footer-download" +msgstr "Скачать заявку" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:114 +msgid "nitrate.code-activation.footer-title" +msgstr "Нужен код?" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:96 +msgid "nitrate.code-activation.input-label" +msgstr "Введите код активации" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:100 +msgid "nitrate.code-activation.placeholder" +msgstr "Вставьте код активации сюда" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:89 +msgid "nitrate.code-activation.renew-title" +msgstr "ПРОДЛИТЬ КОРПОРАТИВНЫЙ" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:111 +msgid "nitrate.code-activation.submit" +msgstr "Активировать" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:90 +msgid "nitrate.code-activation.title" +msgstr "Подключить Корпоративный" + +#: src/app/main/ui/settings/subscription.cljs:906 +msgid "nitrate.contact-sales.button" +msgstr "Связаться с продажами" + +#: src/app/main/ui/settings/subscription.cljs:899 +msgid "nitrate.contact-sales.downgrade-contact-info" +msgstr "" +"Чтобы перейти на тариф, свяжитесь с отделом продаж. Поможем обновить " +"подписку и всё настроить." + +#: src/app/main/ui/settings/subscription.cljs:885 +msgid "nitrate.contact-sales.downgrade-organization-deleted" +msgstr "Ваша организация удалится." + +#: src/app/main/ui/settings/subscription.cljs:887 +msgid "nitrate.contact-sales.downgrade-storage-limited" +msgstr "Общее хранилище, автоверсии и срок восстановления файлов урежутся." + +#: src/app/main/ui/settings/subscription.cljs:886 +msgid "nitrate.contact-sales.downgrade-teams-available" +msgstr "Команды, проекты и файлы выйдут из организации, но останутся доступны." + +#: src/app/main/ui/settings/subscription.cljs:883 +msgid "nitrate.contact-sales.downgrade-title" +msgstr "При понижении тарифа:" + +#: src/app/main/ui/settings/subscription.cljs:880 +msgid "nitrate.contact-sales.title" +msgstr "Перейти на тариф %s?" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:103 +msgid "nitrate.form.cancel-anytime" +msgstr "Оплата — в конце пробного периода. Отмена в любой момент." + +#: src/app/main/ui/nitrate/nitrate_form.cljs:120 +msgid "nitrate.form.contact-trial" +msgstr "Напишите нам, чтобы попробовать Корпоративный 14 дней:" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:119 +msgid "nitrate.form.contact-upgrade" +msgstr "Напишите нам, чтобы перейти на Корпоративный:" + +#: src/app/main/ui/settings/subscription.cljs:854 +msgid "nitrate.form.contact-us-free-trial" +msgstr "Свяжитесь с нами, чтобы попробовать Корпоративный 14 дней:" + +#: src/app/main/ui/settings/subscription.cljs:854 +msgid "nitrate.form.contact-us-upgrade" +msgstr "Свяжитесь с нами, чтобы перейти на Корпоративный:" + +#: src/app/main/ui/settings/subscription.cljs:837 +msgid "nitrate.form.enterprise-description" +msgstr "" +"Сейчас платить не нужно. Оплата — в конце пробного периода. Отмена в любой " +"момент." + +#: src/app/main/ui/nitrate/nitrate_form.cljs:83 +msgid "nitrate.form.enterprise-feature-1" +msgstr "Права от общих до точечных — для команд и проектов" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:85 +msgid "nitrate.form.enterprise-feature-2" +msgstr "Единое управление: кто, что и где — из Admin Console" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:87 +msgid "nitrate.form.enterprise-feature-3" +msgstr "Вся свобода опенсорса и порядок для Вашей организации" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:80, src/app/main/ui/settings/subscription.cljs:832, src/app/main/ui/settings/subscription.cljs:854 +msgid "nitrate.form.enterprise-intro" +msgstr "Всё для безопасной работы команд в масштабе%s" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:92 +msgid "nitrate.form.enterprise.price" +msgstr "с участника в месяц" + +#: src/app/main/ui/settings/subscription.cljs:783, src/app/main/ui/settings/subscription.cljs:850 +msgid "nitrate.form.free-trial-button" +msgstr "14 дней бесплатно" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:114 +msgid "nitrate.form.see-plan" +msgstr "Мой текущий тариф" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:100 +msgid "nitrate.form.start-enterprise" +msgstr "Начать с Корпоративным" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:108, src/app/main/ui/nitrate/nitrate_form.cljs:128 +msgid "nitrate.form.subscribe-with-code" +msgstr "Подписка по коду активации" + +#: src/app/main/ui/nitrate/nitrate_form.cljs:77, src/app/main/ui/settings/subscription.cljs:826 +msgid "nitrate.form.title" +msgstr "Откройте возможности Корпоративного" + +#: src/app/main/ui/dashboard/subscription.cljs:225, src/app/main/ui/nitrate/nitrate_form.cljs:101 +msgid "nitrate.form.try-free" +msgstr "14 дней бесплатно" + +#: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:54 +msgid "nitrate.modal-success.title" +msgstr "Добро пожаловать на Корпоративный тариф!" + +#: src/app/main/ui/settings/subscription.cljs:618 +msgid "nitrate.subscription.active-until" +msgstr "Активна до %s" + +#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:51 +msgid "nitrate.subscription.renew-success" +msgstr "Корпоративная подписка продлена." + +#: src/app/main/ui/settings/subscription.cljs:630, src/app/main/ui/settings/subscription.cljs:933 +msgid "nitrate.subscription.settings.manual-cancel" +msgstr "Отменить подписку" + +#: src/app/main/ui/settings/subscription.cljs:937 +msgid "nitrate.subscription.settings.manual-contact-us" +msgstr "Чтобы отменить корпоративную подписку, напишите нам:" + +#: src/app/main/ui/settings/subscription.cljs:111 +msgid "nitrate.subscription.settings.renew-with-code" +msgstr "Продлить по коду активации" + #: src/app/main/ui/static.cljs:322 msgid "not-found.desc-message.doesnt-exist" msgstr "Эта страница не существует" @@ -3034,12 +4387,84 @@ msgstr "Сделано с любовью и открытым исходным к msgid "not-found.no-permission.already-requested.file" msgstr "Вы уже запросили доступ к этому файлу." +#: src/app/main/ui/static.cljs:283 +msgid "not-found.no-permission.already-requested.or-others.file" +msgstr "" +"Вы уже запросили доступ к этому файлу либо к другим файлам и проектам " +"команды." + +#: src/app/main/ui/static.cljs:289 +msgid "not-found.no-permission.already-requested.or-others.project" +msgstr "" +"Вы уже запросили доступ к этому проекту либо к другим проектам и файлам " +"команды." + +#: src/app/main/ui/static.cljs:288 +msgid "not-found.no-permission.already-requested.project" +msgstr "Вы уже запросили доступ к этому проекту." + +#: src/app/main/ui/static.cljs:303, src/app/main/ui/static.cljs:312 +msgid "not-found.no-permission.ask" +msgstr "ЗАПРОСИТЬ ДОСТУП" + +#: src/app/main/ui/static.cljs:295 +msgid "not-found.no-permission.done.remember" +msgstr "Помните: если владелец разрешит, Вас пригласят в команду." + +#: src/app/main/ui/static.cljs:294 +msgid "not-found.no-permission.done.success" +msgstr "Ваш запрос успешно отправлен!" + +#: src/app/main/ui/static.cljs:300 +msgid "not-found.no-permission.file" +msgstr "У Вас нет доступа к этому файлу." + +#: src/app/main/ui/static.cljs:61, src/app/main/ui/static.cljs:278, src/app/main/ui/static.cljs:284, src/app/main/ui/static.cljs:290, src/app/main/ui/static.cljs:296, src/app/main/ui/static.cljs:305, src/app/main/ui/static.cljs:314, src/app/main/ui/static.cljs:557 +msgid "not-found.no-permission.go-dashboard" +msgstr "Перейти к личным проектам" + +#: src/app/main/ui/static.cljs:302, src/app/main/ui/static.cljs:311 +msgid "not-found.no-permission.if-approves" +msgstr "Если владелец разрешит, Вас пригласят в команду." + +#: src/app/main/ui/static.cljs:477, src/app/main/ui/static.cljs:490 +msgid "not-found.no-permission.penpot-file" +msgstr "Файл Penpot" + +#: src/app/main/ui/static.cljs:277, src/app/main/ui/static.cljs:309 +msgid "not-found.no-permission.project" +msgstr "У Вас нет доступа к этому проекту." + +#: src/app/main/ui/static.cljs:476, src/app/main/ui/static.cljs:488 +msgid "not-found.no-permission.project-name" +msgstr "ПРОЕКТ" + +#: src/app/main/ui/static.cljs:301 +msgid "not-found.no-permission.you-can-ask.file" +msgstr "Чтобы получить файл, попросите владельца команды." + +#: src/app/main/ui/static.cljs:310 +msgid "not-found.no-permission.you-can-ask.project" +msgstr "Чтобы получить проект, попросите владельца команды." + #: src/app/main/data/common.cljs:91 msgid "notifications.by-code.maintenance" msgstr "" "Технический перерыв: сервис будет недоступен короткое время в течение 5 " "минут." +#: src/app/main/data/common.cljs:84 +msgid "notifications.by-code.upgrade-version" +msgstr "Доступна новая версия." + +#: src/app/main/ui/auth/verify_token.cljs:133, src/app/main/ui/auth/verify_token.cljs:136 +msgid "notifications.invitation-canceled" +msgstr "Это приглашение больше недоступно." + +#: src/app/main/ui/dashboard/team.cljs:1096 +msgid "notifications.invitation-deleted" +msgstr "Приглашение успешно удалено" + #: src/app/main/ui/dashboard/team.cljs:213, src/app/main/ui/dashboard/team.cljs:1137 msgid "notifications.invitation-email-sent" msgstr "Приглашение успешно отправлено" @@ -3082,6 +4507,14 @@ msgstr "" "После добавления названия команды, вы сможете пригласить людей " "присоединиться." +#: src/app/main/ui/onboarding/team_choice.cljs:189 +msgid "onboarding.choice.team-up.create-team-enter" +msgstr "Введите название команды" + +#: src/app/main/ui/onboarding/team_choice.cljs:196 +msgid "onboarding.choice.team-up.create-team-name" +msgstr "Название команды" + #: src/app/main/ui/onboarding/team_choice.cljs:237 msgid "onboarding.choice.team-up.create-team-without-invite" msgstr "Создать команду" @@ -3090,6 +4523,12 @@ msgstr "Создать команду" msgid "onboarding.choice.team-up.invite-members" msgstr "Пригласить участников" +#: src/app/main/ui/onboarding/team_choice.cljs:226 +msgid "onboarding.choice.team-up.invite-members-desc" +msgstr "" +"Разработчики, дизайнеры, менеджеры… приглашаются все. Позже сможете позвать " +"ещё." + #: src/app/main/ui/dashboard/team.cljs:292 msgid "onboarding.choice.team-up.roles" msgstr "Пригласить с ролью:" @@ -3149,6 +4588,30 @@ msgstr "Расскажите нам о своей работе" msgid "onboarding.questions.step4.title" msgstr "С чего бы вы хотели начать?" +#: src/app/main/ui/onboarding/questions.cljs:226 +msgid "onboarding.questions.team-size.1001-5000" +msgstr "1001 - 5000" + +#: src/app/main/ui/onboarding/questions.cljs:222 +msgid "onboarding.questions.team-size.101-500" +msgstr "101 - 500" + +#: src/app/main/ui/onboarding/questions.cljs:220 +msgid "onboarding.questions.team-size.2-100" +msgstr "2 - 100" + +#: src/app/main/ui/onboarding/questions.cljs:224 +msgid "onboarding.questions.team-size.501-1000" +msgstr "501 - 1000" + +#: src/app/main/ui/onboarding/questions.cljs:218 +msgid "onboarding.questions.team-size.just-me" +msgstr "Только я" + +#: src/app/main/ui/onboarding/questions.cljs:228 +msgid "onboarding.questions.team-size.more-than-5001" +msgstr "5001+" + #: src/app/main/ui/onboarding/questions.cljs:76 msgid "onboarding.questions.use.education" msgstr "Образование" @@ -3203,10 +4666,42 @@ msgstr "Вот несколько шаблонов." msgid "onboarding.templates.title" msgstr "Заняться дизайном" +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:52, src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs:57, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:121, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:137 +msgid "options.deleted-token-with-name" +msgstr "Токена {%s} нет — удалён." + +#: src/app/plugins/utils.cljs:555 +msgid "plugins.validation.invalid-value" +msgstr "ждали %s, пришло %s (%s)" + +#: src/app/plugins/utils.cljs:569 +msgid "plugins.validation.message" +msgstr "Поле %s неверно: %s" + +#: src/app/plugins/utils.cljs:558 +msgid "plugins.validation.received-value" +msgstr "пришло %s (%s)" + #: src/app/main/ui/auth/recovery.cljs:103 msgid "profile.recovery.go-to-login" msgstr "Перейти к входу" +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:109, src/app/main/ui/settings/restore_shortcuts_modal.cljs:100 +msgid "restore-shortcuts.acction" +msgstr "Действие" + +#: src/app/main/ui/settings/restore_shortcuts_modal.cljs:94 +msgid "restore-shortcuts.modal-text" +msgstr "Эти сочетания вернутся к исходным значениям." + +#: src/app/main/ui/settings/restore_shortcuts_modal.cljs:90 +msgid "restore-shortcuts.modal-title" +msgstr "Вернуть настройки по умолчанию" + +#: src/app/main/ui/settings/restore_shortcuts_modal.cljs:170 +msgid "restore-shortcuts.restore" +msgstr "Восстановить" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:990, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:425 msgid "settings.detach" msgstr "Отсоединить" @@ -3307,6 +4802,14 @@ msgstr "Скопировать" msgid "shortcuts.copy-link" msgstr "Скопировать ссылку" +#: src/app/main/data/workspace/shortcuts.cljs:132 +msgid "shortcuts.copy-props" +msgstr "Скопировать свойства" + +#: src/app/main/data/workspace/shortcuts.cljs:230 +msgid "shortcuts.create-component-variant" +msgstr "Создать компонент / вариант" + #: src/app/main/data/dashboard/shortcuts.cljs:52 msgid "shortcuts.create-new-project" msgstr "Создать новый" @@ -3327,10 +4830,34 @@ msgstr "Удалить" msgid "shortcuts.delete-node" msgstr "Удалить узел" +#: src/app/main/data/workspace/path/shortcuts.cljs:76 +msgid "shortcuts.delete-node-and-segments" +msgstr "Удалить узел и сегменты" + +#: src/app/main/data/workspace/path/shortcuts.cljs:157 +msgid "shortcuts.deselect-all" +msgstr "Снять выделение" + #: src/app/main/data/workspace/shortcuts.cljs:237 msgid "shortcuts.detach-component" msgstr "Отсоединить компонент" +#: src/app/main/ui/shortcuts.cljs:662 +msgid "shortcuts.disable" +msgstr "Отключить сочетание" + +#: src/app/main/ui/settings/shortcuts.cljs:299, src/app/main/ui/settings/shortcuts.cljs:529 +msgid "shortcuts.disabled" +msgstr "Отключено" + +#: src/app/main/ui/shortcuts.cljs:735 +msgid "shortcuts.disabled-shortcut" +msgstr "Отключённое сочетание" + +#: src/app/main/data/workspace/shortcuts.cljs:417 +msgid "shortcuts.draw-arrow" +msgstr "Стрелка" + #: src/app/main/data/workspace/shortcuts.cljs:424 msgid "shortcuts.draw-curve" msgstr "Кривая" @@ -3343,6 +4870,10 @@ msgstr "Эллипс" msgid "shortcuts.draw-frame" msgstr "Доска" +#: src/app/main/data/workspace/shortcuts.cljs:410 +msgid "shortcuts.draw-line" +msgstr "Линия" + #: src/app/main/data/workspace/path/shortcuts.cljs:53 msgid "shortcuts.draw-nodes" msgstr "Нарисовать контур" @@ -3363,6 +4894,22 @@ msgstr "Текст" msgid "shortcuts.duplicate" msgstr "Дублировать" +#: src/app/main/ui/shortcuts.cljs:645 +msgid "shortcuts.edit-modal.conflict" +msgstr "Сочетание занято «%s». Сохранение уберёт его оттуда." + +#: src/app/main/ui/shortcuts.cljs:636 +msgid "shortcuts.edit-modal.reset-pending" +msgstr "Сохраните — сочетание вернётся к исходному:" + +#: src/app/main/ui/shortcuts.cljs:647 +msgid "shortcuts.edit-modal.success" +msgstr "Сочетание свободно, конфликтов нет." + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:157 +msgid "shortcuts.edit-on-settings" +msgstr "Меняется в настройках" + #: src/app/main/data/workspace/grid_layout/shortcuts.cljs:37, src/app/main/data/workspace/path/shortcuts.cljs:175, src/app/main/data/workspace/shortcuts.cljs:177 msgid "shortcuts.escape" msgstr "Отмена" @@ -3371,6 +4918,14 @@ msgstr "Отмена" msgid "shortcuts.export-shapes" msgstr "Экспорт фигур" +#: src/app/main/data/workspace/shortcuts.cljs:184 +msgid "shortcuts.find" +msgstr "Найти" + +#: src/app/main/data/workspace/shortcuts.cljs:188 +msgid "shortcuts.find-and-replace" +msgstr "Найти и заменить" + #: src/app/main/data/workspace/grid_layout/shortcuts.cljs:75, src/app/main/data/workspace/path/shortcuts.cljs:226, src/app/main/data/workspace/shortcuts.cljs:711 msgid "shortcuts.fit-all" msgstr "Увеличить, чтобы вместить все" @@ -3415,6 +4970,10 @@ msgstr "Распределить горизонтально" msgid "shortcuts.hide-ui" msgstr "Показать/скрыть интерфейс" +#: src/app/main/ui/settings/shortcuts.cljs:536 +msgid "shortcuts.import-export" +msgstr "Импорт/экспорт" + #: src/app/main/data/viewer/shortcuts.cljs:19, src/app/main/data/workspace/grid_layout/shortcuts.cljs:57, src/app/main/data/workspace/path/shortcuts.cljs:208, src/app/main/data/workspace/shortcuts.cljs:690, src/app/main/ui/workspace/right_header.cljs:91 msgid "shortcuts.increase-zoom" msgstr "Приблизить" @@ -3431,6 +4990,10 @@ msgstr "Переключить курсив" msgid "shortcuts.join-nodes" msgstr "Соединение узлов" +#: src/app/main/ui/shortcuts.cljs:624 +msgid "shortcuts.key-combo" +msgstr "Нажмите сочетание клавиш" + #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Переключить междустрочность" @@ -3495,9 +5058,21 @@ msgstr "Переместить вверх" msgid "shortcuts.next-frame" msgstr "Следующая доска" +#: src/app/main/ui/settings/shortcuts.cljs:510 +msgid "shortcuts.no-disabled" +msgstr "Отключённых сочетаний нет" + +#: src/app/main/ui/settings/shortcuts.cljs:500 +msgid "shortcuts.no-personalized" +msgstr "Откройте вкладку «Все» и настройте сочетания." + +#: src/app/main/ui/settings/shortcuts.cljs:242, src/app/main/ui/settings/shortcuts.cljs:491 +msgid "shortcuts.no-shortcuts" +msgstr "Под поиск сочетаний ничего нет" + #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:154 msgid "shortcuts.not-found" -msgstr "Ярлыки не найдены" +msgstr "Сочетания клавиш не найдены" #: src/app/main/data/workspace/shortcuts.cljs:848 msgid "shortcuts.opacity-0" @@ -3571,10 +5146,34 @@ msgstr "Перейти к рабочей области" msgid "shortcuts.or" msgstr " или " +#: src/app/main/ui/shortcuts.cljs:605, src/app/main/ui/shortcuts.cljs:747 +msgid "shortcuts.overwritten-by" +msgstr "Сочетание переназначено на %s" + +#: src/app/main/ui/settings/shortcuts.cljs:468 +msgid "shortcuts.page" +msgstr "Страница сочетаний" + #: src/app/main/data/workspace/path/shortcuts.cljs:139, src/app/main/data/workspace/shortcuts.cljs:116 msgid "shortcuts.paste" msgstr "Вставить" +#: src/app/main/data/workspace/shortcuts.cljs:139 +msgid "shortcuts.paste-props" +msgstr "Вставить свойства" + +#: src/app/main/data/workspace/shortcuts.cljs:125 +msgid "shortcuts.paste-replace" +msgstr "Вставить и заменить" + +#: src/app/main/ui/settings/shortcuts.cljs:296, src/app/main/ui/settings/shortcuts.cljs:523 +msgid "shortcuts.personalized" +msgstr "Свои" + +#: src/app/main/data/workspace/shortcuts.cljs:832 +msgid "shortcuts.plugins" +msgstr "Управление плагинами" + #: src/app/main/data/viewer/shortcuts.cljs:69 msgid "shortcuts.prev-frame" msgstr "Предыдущая доска" @@ -3583,10 +5182,18 @@ msgstr "Предыдущая доска" msgid "shortcuts.redo" msgstr "Вернуть" +#: src/app/main/ui/settings/shortcuts.cljs:479 +msgid "shortcuts.reload-hint" +msgstr "После правки сочетаний обновите другие вкладки Penpot." + #: src/app/main/data/workspace/shortcuts.cljs:195 msgid "shortcuts.rename" msgstr "Переименовать" +#: src/app/main/ui/shortcuts.cljs:670 +msgid "shortcuts.reset" +msgstr "Сбросить" + #: src/app/main/data/viewer/shortcuts.cljs:48, src/app/main/data/workspace/grid_layout/shortcuts.cljs:69, src/app/main/data/workspace/path/shortcuts.cljs:220, src/app/main/data/workspace/shortcuts.cljs:704 msgid "shortcuts.reset-zoom" msgstr "Сбросить масштаб" @@ -3632,13 +5239,17 @@ msgstr "Выбрать предыдущий слой" msgid "shortcuts.separate-nodes" msgstr "Отдельные узлы" +#: src/app/main/data/workspace/shortcuts.cljs:167 +msgid "shortcuts.show-measure" +msgstr "Показать размеры" + #: src/app/main/data/workspace/shortcuts.cljs:592 msgid "shortcuts.show-pixel-grid" msgstr "Показать/скрыть пиксельную сетку" #: src/app/main/data/workspace/shortcuts.cljs:630 msgid "shortcuts.show-shortcuts" -msgstr "Показать/скрыть горячие клавиши" +msgstr "Показать/скрыть сочетания клавиш" #: src/app/main/data/workspace/path/shortcuts.cljs:119 msgid "shortcuts.snap-nodes" @@ -3719,7 +5330,7 @@ msgstr "Установить миниатюры" #: src/app/main/ui/settings/shortcuts.cljs:170, src/app/main/ui/workspace/sidebar/shortcuts.cljs:132 msgid "shortcuts.title" -msgstr "Горячие клавиши" +msgstr "Сочетания клавиш" #: src/app/main/data/workspace/shortcuts.cljs:578 msgid "shortcuts.toggle-alignment" @@ -3733,6 +5344,10 @@ msgstr "Переключить ресурсы" msgid "shortcuts.toggle-colorpalette" msgstr "Переключить палитру цветов" +#: src/app/main/data/workspace/shortcuts.cljs:439 +msgid "shortcuts.toggle-comments-visibility" +msgstr "Показать/скрыть комментарии" + #: src/app/main/data/workspace/shortcuts.cljs:488 msgid "shortcuts.toggle-focus-mode" msgstr "Переключить режим фокуса" @@ -3829,6 +5444,74 @@ msgstr "Увеличить зум-объектив" msgid "shortcuts.zoom-selected" msgstr "Приблизить выбранное" +#: src/app/main/ui/dashboard/subscription.cljs:207 +msgid "subscription.banner.create-organization-info" +msgstr "" +"Создайте организацию и посмотрите Корпоративный в деле. Задавайте правила, " +"управляйте командами — новый уровень контроля над дизайном." + +#: src/app/main/ui/dashboard/subscription.cljs:205 +msgid "subscription.banner.see-enterprise" +msgstr "Взгляните на Корпоративный в деле" + +#: src/app/main/ui/dashboard/subscription.cljs:269 +msgid "subscription.current-plan.enterprise" +msgstr "Корпоративный" + +#: src/app/main/ui/dashboard/subscription.cljs:268, src/app/main/ui/settings/subscription.cljs:785 +msgid "subscription.current-plan.nitrate" +msgstr "Корпоративный" + +#: src/app/main/ui/dashboard/subscription.cljs:267 +msgid "subscription.current-plan.nitrate-trial" +msgstr "Корпоративный (пробный)" + +#: src/app/main/ui/dashboard/subscription.cljs:262 +msgid "subscription.current-plan.professional" +msgstr "Профессиональный" + +#: src/app/main/ui/dashboard/subscription.cljs:257 +msgid "subscription.current-plan.title" +msgstr "Ваша подписка" + +#: src/app/main/ui/dashboard/subscription.cljs:265 +msgid "subscription.current-plan.unlimited" +msgstr "Безлимитный" + +#: src/app/main/ui/dashboard/subscription.cljs:264 +msgid "subscription.current-plan.unlimited-trial" +msgstr "Безлимитный (пробный)" + +#: src/app/main/ui/dashboard/subscription.cljs:245 +msgid "subscription.dashboard.banner.renew" +msgstr "Продлить" + +#: src/app/main/ui/dashboard/subscription.cljs:232 +msgid "subscription.dashboard.banner.renew-subscription" +msgstr "Продлите подписку" + +#: src/app/main/ui/dashboard/subscription.cljs:238 +msgid "subscription.dashboard.banner.subscription-expire-days" +msgstr "Корпоративная подписка истекает через %s дней (%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:236 +msgid "subscription.dashboard.banner.subscription-expired" +msgstr "Корпоративная подписка истекла. (%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:217 +msgid "subscription.dashboard.banner.unlock-features" +msgstr "Откройте возможности Корпоративного" + +#: src/app/main/ui/dashboard/subscription.cljs:219 +msgid "subscription.dashboard.banner.unlock-features-description-text" +msgstr "" +"Точечные права и полный контроль — без потери свободы опенсорса, которую " +"любит команда." + +#: src/app/main/ui/dashboard/subscription.cljs:224 +msgid "subscription.dashboard.banner.upgrade-nitrate" +msgstr "Начать с Корпоративным" + #: src/app/main/ui/dashboard/subscription.cljs:117, src/app/main/ui/dashboard/subscription.cljs:316 msgid "subscription.dashboard.power-up.enterprise-plan" msgstr "Корпоративный тариф" @@ -3849,6 +5532,13 @@ msgstr "Дополнительное хранилище, восстановле msgid "subscription.dashboard.power-up.professional.top-title" msgstr "Профессиональный тариф" +#: src/app/main/ui/dashboard/subscription.cljs:97 +#, markdown +msgid "subscription.dashboard.power-up.trial.bottom-description" +msgstr "" +"Нравится пробный период? Откройте полный доступ " +"навсегда.[Подписаться|target:self](%s)" + #: src/app/main/ui/dashboard/subscription.cljs:96 msgid "subscription.dashboard.power-up.trial.top-title" msgstr "Безлимитный тариф (пробный)" @@ -3857,14 +5547,162 @@ msgstr "Безлимитный тариф (пробный)" msgid "subscription.dashboard.power-up.unlimited-plan" msgstr "Безлимитный тариф" +#: src/app/main/ui/dashboard/subscription.cljs:104 +#, markdown +msgid "subscription.dashboard.power-up.unlimited.bottom-text" +msgstr "" +"Безлимитное хранилище, долгое восстановление файлов и сколько угодно " +"редакторов для команд — по фиксу. [Взгляните на " +"Корпоративный.|target:self](%s)" + #: src/app/main/ui/dashboard/subscription.cljs:84, src/app/main/ui/dashboard/subscription.cljs:95, src/app/main/ui/dashboard/subscription.cljs:102, src/app/main/ui/dashboard/subscription.cljs:111, src/app/main/ui/dashboard/subscription.cljs:116 msgid "subscription.dashboard.power-up.your-subscription" msgstr "Ваша подписка:" +#: src/app/main/ui/dashboard/subscription.cljs:350 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "У Вас %s редакторов в командах, а Профессиональный покрывает до 8." + +#: src/app/main/ui/dashboard/subscription.cljs:358 +#, markdown +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "" +"Перейдите на Безлимитный или Корпоративный — больше редакторов, хранилища и " +"восстановлений. [Подписаться.|target:self](%s)" + #: src/app/main/ui/dashboard/subscription.cljs:291 msgid "subscription.dashboard.team-plan" msgstr "Командный тариф" +#: src/app/main/ui/dashboard/subscription.cljs:353 +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "Команда растёт! Безлимитный покрывает до %s редакторов, а у Вас уже %s." + +#: src/app/main/ui/dashboard/subscription.cljs:361 +#, markdown +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "Поднимите тариф под число редакторов. [Подписаться.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:338 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"В будущие счета попадут только новые редакторы Ваших команд. От 25 " +"редакторов — фикс 175 $/мес." + +#: src/app/main/ui/dashboard/subscription.cljs:334 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "Приглашения на Безлимитном" + +#: src/app/main/ui/settings/subscription.cljs:432 +msgid "subscription.error.nitrate.checkout-cancelled" +msgstr "Оплата не прошла. Попробуйте, когда будете готовы." + +#: src/app/main/ui/settings/subscription.cljs:431, src/app/main/ui/settings/subscription.cljs:451 +msgid "subscription.error.nitrate.checkout-failed" +msgstr "" +"Оплата не прошла. Попробуйте снова. Если не выйдет — напишите нам: " +"support@penpot.app." + +#: src/app/main/ui/settings/sidebar.cljs:126, src/app/main/ui/settings/subscription.cljs:550, src/app/main/ui/settings/subscription.cljs:611 +msgid "subscription.labels" +msgstr "Подписка" + +#: src/app/main/ui/settings/subscription.cljs:105 +msgid "subscription.settings.activate-by-code" +msgstr "Введите код активации" + +#: src/app/main/ui/settings/subscription.cljs:660, src/app/main/ui/settings/subscription.cljs:686 +msgid "subscription.settings.add-payment-to-continue" +msgstr "Добавьте способ оплаты, чтобы продолжить после пробного периода" + +#: src/app/main/ui/settings/subscription.cljs:626 +msgid "subscription.settings.admin-console" +msgstr "Admin Console" + +#: src/app/main/ui/settings/subscription.cljs:654, src/app/main/ui/settings/subscription.cljs:745 +msgid "subscription.settings.benefits.all-professional-benefits" +msgstr "Всё из Профессионального плюс:" + +#: src/app/main/ui/settings/subscription.cljs:667, src/app/main/ui/settings/subscription.cljs:680, src/app/main/ui/settings/subscription.cljs:691, src/app/main/ui/settings/subscription.cljs:761 +msgid "subscription.settings.benefits.all-unlimited-benefits" +msgstr "Всё из Безлимитного плюс:" + +#: src/app/main/ui/settings/subscription.cljs:619, src/app/main/ui/settings/subscription.cljs:776 +msgid "subscription.settings.benefits.nitrate-unlimited-benefits" +msgstr "Всё из Безлимитного и ещё:" + +#: src/app/main/ui/settings/subscription.cljs:64 +msgid "subscription.settings.editors" +msgstr "(x %s редакторов)" + +#: src/app/main/ui/dashboard/subscription.cljs:299, src/app/main/ui/settings/subscription.cljs:124, src/app/main/ui/settings/subscription.cljs:467, src/app/main/ui/settings/subscription.cljs:469, src/app/main/ui/settings/subscription.cljs:605, src/app/main/ui/settings/subscription.cljs:616, src/app/main/ui/settings/subscription.cljs:689, src/app/main/ui/settings/subscription.cljs:757, src/app/main/ui/settings/subscription.cljs:773 +msgid "subscription.settings.enterprise" +msgstr "Корпоративный" + +#: src/app/main/ui/settings/subscription.cljs:120, src/app/main/ui/settings/subscription.cljs:604, src/app/main/ui/settings/subscription.cljs:678 +msgid "subscription.settings.enterprise-trial" +msgstr "Корпоративный (пробный)" + +#: src/app/main/ui/settings/subscription.cljs:682, src/app/main/ui/settings/subscription.cljs:693, src/app/main/ui/settings/subscription.cljs:763 +msgid "subscription.settings.enterprise.autosave" +msgstr "Автоверсии за 90 дней и восстановление файлов" + +#: src/app/main/ui/settings/subscription.cljs:683, src/app/main/ui/settings/subscription.cljs:694, src/app/main/ui/settings/subscription.cljs:764 +msgid "subscription.settings.enterprise.capped-bill" +msgstr "Фикс в месяц" + +#: src/app/main/ui/settings/subscription.cljs:624, src/app/main/ui/settings/subscription.cljs:781 +msgid "subscription.settings.enterprise.nitrate.advanced-control" +msgstr "Продвинутое управление:" + +#: src/app/main/ui/settings/subscription.cljs:622, src/app/main/ui/settings/subscription.cljs:779 +msgid "subscription.settings.enterprise.nitrate.enterprise-security" +msgstr "Корпоративная безопасность:" + +#: src/app/main/ui/settings/subscription.cljs:620, src/app/main/ui/settings/subscription.cljs:777 +msgid "subscription.settings.enterprise.nitrate.multi-organization-management" +msgstr "Управление организациями:" + +#: src/app/main/ui/settings/subscription.cljs:623, src/app/main/ui/settings/subscription.cljs:780 +msgid "subscription.settings.enterprise.nitrate.native-sso" +msgstr "SAML/SSO из коробки и полный журнал аудита для отчётности." + +#: src/app/main/ui/settings/subscription.cljs:625, src/app/main/ui/settings/subscription.cljs:782 +msgid "subscription.settings.enterprise.nitrate.plugin-whitelisting" +msgstr "" +"Точечный белый список плагинов, включая наш закрытый MCP-сервер для связи с " +"ИИ." + +#: src/app/main/ui/settings/subscription.cljs:621, src/app/main/ui/settings/subscription.cljs:778 +msgid "subscription.settings.enterprise.nitrate.support-team" +msgstr "Сложные иерархии команд и отдельные подорганизации." + +#: src/app/main/ui/settings/subscription.cljs:681, src/app/main/ui/settings/subscription.cljs:692, src/app/main/ui/settings/subscription.cljs:762 +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "Безлимитное хранилище" + +#: src/app/main/ui/dashboard/subscription.cljs:304, src/app/main/ui/settings/subscription.cljs:629, src/app/main/ui/settings/subscription.cljs:658, src/app/main/ui/settings/subscription.cljs:671, src/app/main/ui/settings/subscription.cljs:684, src/app/main/ui/settings/subscription.cljs:695 +msgid "subscription.settings.manage-your-subscription" +msgstr "Управляйте подпиской" + +#: src/app/main/ui/settings/subscription.cljs:352 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "Добавить платёжные данные" + +#: src/app/main/ui/settings/subscription.cljs:339 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"Добавьте платёжные данные сейчас — подписка продолжится без сбоев, а " +"опенсорс получит поддержку. Пока без списаний." + +#: src/app/main/ui/settings/subscription.cljs:347 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "Пока пропустить и начать пробный период" + +#: src/app/main/ui/settings/subscription.cljs:270 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "Помогите нам расти — и пробный период станет проще" + #: src/app/main/ui/settings/subscription.cljs:136 msgid "subscription.settings.management.dialog.currently-editors-title" msgid_plural "subscription.settings.management.dialog.currently-editors-title" @@ -3875,18 +5713,135 @@ msgstr[1] "" "В настоящее время в ваших командах есть %s человек(а), которые могут " "редактировать." +#: src/app/main/ui/settings/subscription.cljs:284 +msgid "subscription.settings.management.dialog.downgrade" +msgstr "Учтите: низкий тариф — меньше хранилища, короче бэкапы и история." + +#: src/app/main/ui/settings/subscription.cljs:138 +msgid "subscription.settings.management.dialog.editors" +msgstr "Редакторы" + +#: src/app/main/ui/settings/subscription.cljs:145 +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(Владельцы, администраторы и редакторы. Наблюдатели не в счёт.)" + +#: src/app/main/ui/settings/subscription.cljs:317 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"Редакторов не может быть меньше, чем сейчас. Смените роль (с редактора и " +"администратора на наблюдателя) тем, кто не правит файлы." + #: src/app/main/ui/settings/subscription.cljs:313 msgid "subscription.settings.management.dialog.payment-explanation" msgstr "Оплата после пробного периода. Кредитная карта сейчас не требуется." +#: src/app/main/ui/settings/subscription.cljs:306, src/app/main/ui/settings/subscription.cljs:310 +#, markdown +msgid "subscription.settings.management.dialog.price-month" +msgstr "**$%s**/мес." + +#: src/app/main/ui/settings/subscription.cljs:271 +msgid "subscription.settings.management.dialog.title" +msgstr "Примените %s к командам" + +#: src/app/main/ui/settings/subscription.cljs:320 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"Совет: поднимите число мест заранее, до приглашений. От 25 редакторов — " +"фикс 175 $/мес." + #: src/app/main/ui/settings/subscription.cljs:714 msgid "subscription.settings.member-since" msgstr "Участник Penpot с: %s" +#: src/app/main/ui/settings/subscription.cljs:736, src/app/main/ui/settings/subscription.cljs:751, src/app/main/ui/settings/subscription.cljs:767, src/app/main/ui/settings/subscription.cljs:787 +msgid "subscription.settings.more-information" +msgstr "Подробнее" + +#: src/app/main/ui/settings/subscription.cljs:775, src/app/main/ui/settings/subscription.cljs:835 +msgid "subscription.settings.organization-member-month" +msgstr "участник организации в месяц" + +#: src/app/main/ui/settings/subscription.cljs:717 +msgid "subscription.settings.other-plans" +msgstr "Другие тарифы Penpot" + +#: src/app/main/ui/settings/subscription.cljs:760 +msgid "subscription.settings.price-organization-month" +msgstr "организация в месяц" + +#: src/app/main/ui/settings/subscription.cljs:721, src/app/main/ui/settings/subscription.cljs:744 +msgid "subscription.settings.price-user-month" +msgstr "пользователь в месяц" + +#: src/app/main/ui/dashboard/subscription.cljs:294, src/app/main/ui/settings/subscription.cljs:122, src/app/main/ui/settings/subscription.cljs:638, src/app/main/ui/settings/subscription.cljs:719 +msgid "subscription.settings.professional" +msgstr "Профессиональный" + +#: src/app/main/ui/settings/subscription.cljs:643, src/app/main/ui/settings/subscription.cljs:726 +msgid "subscription.settings.professional.autosave-benefit" +msgstr "Автоверсии за 7 дней и восстановление файлов" + +#: src/app/main/ui/settings/subscription.cljs:647, src/app/main/ui/settings/subscription.cljs:730 +msgid "subscription.settings.professional.selfhost.community-support" +msgstr "Все возможности дизайна и прототипирования. Поддержка сообщества." + +#: src/app/main/ui/settings/subscription.cljs:641, src/app/main/ui/settings/subscription.cljs:724 +msgid "subscription.settings.professional.selfhost.control-over-data" +msgstr "Полный контроль над данными и инфраструктурой." + +#: src/app/main/ui/settings/subscription.cljs:644, src/app/main/ui/settings/subscription.cljs:727 +msgid "subscription.settings.professional.selfhost.unlimited-users" +msgstr "Сколько угодно пользователей, команд и файлов." + +#: src/app/main/ui/settings/subscription.cljs:640, src/app/main/ui/settings/subscription.cljs:723 +msgid "subscription.settings.professional.storage-benefit" +msgstr "10 ГБ хранилища" + +#: src/app/main/ui/settings/subscription.cljs:646, src/app/main/ui/settings/subscription.cljs:729 +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "Сколько угодно команд. До 8 редакторов в Ваших командах." + #: src/app/main/ui/settings/subscription.cljs:60 msgid "subscription.settings.recommended" msgstr "Рекомендуется" +#: src/app/main/ui/settings/subscription.cljs:614 +msgid "subscription.settings.section-plan" +msgstr "Ваша подписка" + +#: src/app/main/ui/settings/subscription.cljs:367 +msgid "subscription.settings.start-trial" +msgstr "Начать пробный период" + +#: src/app/main/ui/settings/subscription.cljs:332, src/app/main/ui/settings/subscription.cljs:731, src/app/main/ui/settings/subscription.cljs:749, src/app/main/ui/settings/subscription.cljs:765, src/app/main/ui/settings/subscription.cljs:783, src/app/main/ui/settings/subscription.cljs:850 +msgid "subscription.settings.subscribe" +msgstr "Подписаться" + +#: src/app/main/ui/settings/subscription.cljs:707 +msgid "subscription.settings.subscribed-since" +msgstr "Тариф %s у Вас с %s." + +#: src/app/main/ui/settings/subscription.cljs:399 +msgid "subscription.settings.success.dialog.description" +msgstr "Подпиской управляйте когда угодно — страница «Подписка» в данных аккаунта." + +#: src/app/main/ui/settings/subscription.cljs:401 +msgid "subscription.settings.success.dialog.footer" +msgstr "Наслаждайтесь тарифом!" + +#: src/app/main/ui/settings/subscription.cljs:397 +msgid "subscription.settings.success.dialog.thanks" +msgstr "Спасибо, что выбрали тариф Penpot %s!" + +#: src/app/main/ui/settings/subscription.cljs:394 +msgid "subscription.settings.success.dialog.title" +msgstr "Вы — %s!" + +#: src/app/main/ui/settings/subscription.cljs:749, src/app/main/ui/settings/subscription.cljs:765 +msgid "subscription.settings.try-it-free" +msgstr "14 дней бесплатно" + #: src/app/main/ui/dashboard/subscription.cljs:297, src/app/main/ui/settings/subscription.cljs:123, src/app/main/ui/settings/subscription.cljs:468, src/app/main/ui/settings/subscription.cljs:602, src/app/main/ui/settings/subscription.cljs:665, src/app/main/ui/settings/subscription.cljs:741 msgid "subscription.settings.unlimited" msgstr "Безлимитный" @@ -3899,6 +5854,18 @@ msgstr "Безлимитный (пробный)" msgid "subscription.settings.unlimited.autosave-benefit" msgstr "30-дневное автосохранение версий и восстановление файлов" +#: src/app/main/ui/settings/subscription.cljs:657, src/app/main/ui/settings/subscription.cljs:670, src/app/main/ui/settings/subscription.cljs:748 +msgid "subscription.settings.unlimited.bill" +msgstr "Не более 175 $/мес." + +#: src/app/main/ui/settings/subscription.cljs:655, src/app/main/ui/settings/subscription.cljs:668, src/app/main/ui/settings/subscription.cljs:746 +msgid "subscription.settings.unlimited.storage-benefit" +msgstr "25 ГБ хранилища" + +#: src/app/main/ui/dashboard/subscription.cljs:329 +msgid "subscription.workspace.header.menu.option.power-up" +msgstr "Прокачайте тариф" + #: src/app/main/ui/workspace/sidebar/versions.cljs:62 #, markdown msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" @@ -3954,6 +5921,10 @@ msgstr "Penpot — свобода в дизайне для команд" msgid "title.settings.feedback" msgstr "Оставить отзыв - Penpot" +#: src/app/main/ui/settings/integrations.cljs:646 +msgid "title.settings.integrations" +msgstr "Интеграции - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Уведомления - Penpot" @@ -4004,7 +5975,7 @@ msgstr "На странице не найдено ни одного кадра." #: src/app/main/ui/viewer.cljs:577 msgid "viewer.frame-not-found" -msgstr "Кадр не найден." +msgstr "Доска не найдена." #: src/app/main/ui/viewer/header.cljs:341 msgid "viewer.header.comments-section" @@ -4046,6 +6017,52 @@ msgstr "Показывать взаимодействия по клику" msgid "viewer.header.sitemap" msgstr "План сайта" +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:76 +msgid "webgl.modals.webgl-unavailable.cta-dashboard" +msgstr "В панель управления" + +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:78 +msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" +msgstr "Руководство по неполадкам" + +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:62 +msgid "webgl.modals.webgl-unavailable.message" +msgstr "" +"WebGL недоступен в браузере, а без него Penpot не работает. Проверьте " +"настройки браузера и закройте тяжёлые вкладки." + +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:58 +msgid "webgl.modals.webgl-unavailable.title" +msgstr "Упс! WebGL недоступен" + +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:71 +msgid "webgl.modals.webgl-unavailable.troubleshooting.after" +msgstr " — там про настройки браузера, ускорение GPU, драйверы и частые сбои." + +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:65 +msgid "webgl.modals.webgl-unavailable.troubleshooting.before" +msgstr "Загляните в наше " + +#: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:70 +msgid "webgl.modals.webgl-unavailable.troubleshooting.link" +msgstr "руководство по WebGL" + +#: src/app/main/ui/settings/options.cljs:105 +msgid "webgl.toast.webgl-render-disabled" +msgstr "Рендеринг WebGL выключен" + +#: src/app/main/ui/settings/options.cljs:104 +msgid "webgl.toast.webgl-render-enabled" +msgstr "Рендеринг WebGL включён" + +#: src/app/render_wasm/api.cljs:2462 +msgid "webgl.webgl-context-lost.toast" +msgstr "Контекст WebGL потерян" + +#: src/app/render_wasm/api.cljs:2485 +msgid "webgl.webgl-context-recovered.toast" +msgstr "Контекст WebGL восстановлен" + #: src/app/main/ui/dashboard/team.cljs:1489 msgid "webhooks.last-delivery.success" msgstr "Последняя доставка прошла успешно." @@ -4094,10 +6111,26 @@ msgstr "Все ресурсы" msgid "workspace.assets.colors" msgstr "Цвета" +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:520 +msgid "workspace.assets.colors.add-color" +msgstr "Добавить цвет" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:91 +msgid "workspace.assets.component-group-options" +msgstr "Настройки группы компонентов" + #: src/app/main/ui/dashboard/grid.cljs:152, src/app/main/ui/dashboard/grid.cljs:167, src/app/main/ui/workspace/sidebar/assets/components.cljs:588, src/app/main/ui/workspace/sidebar/assets.cljs:182 msgid "workspace.assets.components" msgstr "Компоненты" +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:609 +msgid "workspace.assets.components.add-component" +msgstr "Добавить компонент" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:177, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:613 +msgid "workspace.assets.components.num-variants" +msgstr "Вариантов: %s" + #: src/app/main/ui/workspace/sidebar/assets/groups.cljs:160 msgid "workspace.assets.create-group" msgstr "Создать группу" @@ -4112,6 +6145,14 @@ msgstr "" msgid "workspace.assets.delete" msgstr "Удалить" +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:74 +msgid "workspace.assets.delete-group" +msgstr "Удалить группу" + +#: src/app/main/ui/workspace/context_menu.cljs:824 +msgid "workspace.assets.delete-pages" +msgstr "Удалить страницы" + #: src/app/main/ui/workspace/context_menu.cljs:833, src/app/main/ui/workspace/sidebar/assets/colors.cljs:262, src/app/main/ui/workspace/sidebar/assets/typographies.cljs:472, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:817 msgid "workspace.assets.duplicate" msgstr "Дублировать" @@ -4128,6 +6169,10 @@ msgstr "Редактировать" msgid "workspace.assets.filter" msgstr "Фильтр" +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:194, src/app/main/ui/workspace/colorpicker/libraries.cljs:347, src/app/main/ui/workspace/sidebar/assets/components.cljs:604 +msgid "workspace.assets.grid-view" +msgstr "Вид сетки" + #: src/app/main/ui/workspace/sidebar/assets/colors.cljs:269, src/app/main/ui/workspace/sidebar/assets/components.cljs:654, src/app/main/ui/workspace/sidebar/assets/typographies.cljs:481 msgid "workspace.assets.group" msgstr "Группа" @@ -4136,10 +6181,18 @@ msgstr "Группа" msgid "workspace.assets.group-name" msgstr "Имя группы" +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:195, src/app/main/ui/workspace/colorpicker/libraries.cljs:346, src/app/main/ui/workspace/sidebar/assets/components.cljs:600 +msgid "workspace.assets.list-view" +msgstr "Вид списка" + #: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:116, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:697 msgid "workspace.assets.local-library" msgstr "локальная библиотека" +#: src/app/main/ui/workspace/sidebar/assets.cljs:212 +msgid "workspace.assets.manage-library" +msgstr "Управлять библиотекой" + #: src/app/main/ui/workspace/color_palette.cljs:204, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:323 msgid "workspace.assets.not-found" msgstr "Ресурсы не найдены" @@ -4178,6 +6231,10 @@ msgstr "Сортировка" msgid "workspace.assets.typography" msgstr "Типографика" +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:87, src/app/main/ui/workspace/sidebar/assets/typographies.cljs:431 +msgid "workspace.assets.typography.add-typography" +msgstr "Добавить типографику" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:855 msgid "workspace.assets.typography.font-size" msgstr "Размер" @@ -4210,10 +6267,46 @@ msgstr "Регистр" msgid "workspace.assets.ungroup" msgstr "Разгруппировать" +#: src/app/main/data/workspace/clipboard.cljs:1170 +msgid "workspace.clipboard.copying" +msgstr "Копирование картинки…" + +#: src/app/main/data/workspace/clipboard.cljs:1192 +msgid "workspace.clipboard.image-copied" +msgstr "Картинка скопирована в буфер обмена" + +#: src/app/main/data/workspace/clipboard.cljs:1198 +msgid "workspace.clipboard.image-copy-failed" +msgstr "Не удалось скопировать картинку" + +#: src/app/main/ui/workspace/colorpicker.cljs:447 +msgid "workspace.colorpicker.color-picker" +msgstr "Взять цвет с экрана" + +#: src/app/main/ui/workspace/colorpicker.cljs:439, src/app/main/ui/workspace/colorpicker.cljs:454 +msgid "workspace.colorpicker.color-tokens" +msgstr "Токены цвета" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:512 +msgid "workspace.component.swap.loop-error" +msgstr "Компоненты нельзя вкладывать в себя." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:511 +msgid "workspace.component.switch.loop-error-multi" +msgstr "Часть копий не переключить: компоненты нельзя вкладывать в себя." + #: src/app/main/ui/workspace/context_menu.cljs:980 msgid "workspace.context-menu.grid-cells.area" msgstr "Создать область" +#: src/app/main/ui/workspace/context_menu.cljs:991 +msgid "workspace.context-menu.grid-cells.copy-columns" +msgstr "Скопировать столбцы" + +#: src/app/main/ui/workspace/context_menu.cljs:987 +msgid "workspace.context-menu.grid-cells.copy-rows" +msgstr "Скопировать строки" + #: src/app/main/ui/workspace/context_menu.cljs:983 msgid "workspace.context-menu.grid-cells.create-board" msgstr "Создать доску" @@ -4222,6 +6315,10 @@ msgstr "Создать доску" msgid "workspace.context-menu.grid-cells.merge" msgstr "Объединить ячейки" +#: src/app/main/ui/workspace/context_menu.cljs:995 +msgid "workspace.context-menu.grid-cells.paste-tracks" +msgstr "Вставить" + #: src/app/main/ui/workspace/context_menu.cljs:902 msgid "workspace.context-menu.grid-track.column.add-after" msgstr "Добавить 1 столбец справа" @@ -4262,6 +6359,18 @@ msgstr "Удалить строку и фигуры" msgid "workspace.context-menu.grid-track.row.duplicate" msgstr "Дублировать строку" +#: src/app/main/ui/workspace/context_menu.cljs:1029 +msgid "workspace.context-menu.guides.change-color" +msgstr "Цвет направляющей" + +#: src/app/main/ui/workspace/context_menu.cljs:1041 +msgid "workspace.context-menu.guides.remove" +msgstr "Убрать направляющую" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:52 +msgid "workspace.debug.title" +msgstr "Отладка" + #: src/app/main/ui/workspace/sidebar/layers.cljs:885 msgid "workspace.focus.focus-mode" msgstr "Режим фокусировки" @@ -4302,6 +6411,10 @@ msgstr "Отключить привязку к пикселю" msgid "workspace.header.menu.disable-snap-ruler-guides" msgstr "Отключить привязку к направляющим линейки" +#: src/app/main/ui/workspace/main_menu.cljs:341 +msgid "workspace.header.menu.disable-webgl" +msgstr "Выключить рендеринг WebGL (бета)" + #: src/app/main/ui/workspace/main_menu.cljs:294 msgid "workspace.header.menu.enable-dynamic-alignment" msgstr "Включить активное выравнивание" @@ -4322,9 +6435,25 @@ msgstr "Включить привязку к пикселю" msgid "workspace.header.menu.enable-snap-ruler-guides" msgstr "Привязка к направляющим линейки" +#: src/app/main/ui/workspace/main_menu.cljs:342 +msgid "workspace.header.menu.enable-webgl" +msgstr "Включить рендеринг WebGL (бета)" + +#: src/app/main/ui/workspace/main_menu.cljs:535 +msgid "workspace.header.menu.find" +msgstr "Найти" + +#: src/app/main/ui/workspace/main_menu.cljs:542 +msgid "workspace.header.menu.find-and-replace" +msgstr "Найти и заменить" + #: src/app/main/ui/workspace/main_menu.cljs:465 msgid "workspace.header.menu.hide-artboard-names" -msgstr "Скрыть имена кадров" +msgstr "Скрыть имена досок" + +#: src/app/main/ui/workspace/main_menu.cljs:426 +msgid "workspace.header.menu.hide-comments" +msgstr "Скрыть комментарии" #: src/app/main/ui/workspace/main_menu.cljs:401 msgid "workspace.header.menu.hide-guides" @@ -4346,6 +6475,26 @@ msgstr "Скрыть линейки" msgid "workspace.header.menu.hide-textpalette" msgstr "Скрыть палитру шрифтов" +#: src/app/main/ui/workspace/main_menu.cljs:415 +msgid "workspace.header.menu.lock-guides" +msgstr "Закрепить направляющие" + +#: src/app/main/ui/workspace/main_menu.cljs:874 +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Подключить" + +#: src/app/main/ui/workspace/main_menu.cljs:873 +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Отключить" + +#: src/app/main/ui/workspace/main_menu.cljs:883 +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Управлять (статус: отключён)" + +#: src/app/main/ui/workspace/main_menu.cljs:882 +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Управлять (статус: включён)" + #: src/app/main/ui/workspace/main_menu.cljs:1038 msgid "workspace.header.menu.option.edit" msgstr "Изменить" @@ -4358,6 +6507,10 @@ msgstr "Файл" msgid "workspace.header.menu.option.help-info" msgstr "Помощь и информация" +#: src/app/main/ui/workspace/main_menu.cljs:1104 +msgid "workspace.header.menu.option.mcp" +msgstr "MCP-сервер" + #: src/app/main/ui/workspace/main_menu.cljs:1064 msgid "workspace.header.menu.option.preferences" msgstr "Предпочтения" @@ -4376,7 +6529,11 @@ msgstr "Выбрать все" #: src/app/main/ui/workspace/main_menu.cljs:466 msgid "workspace.header.menu.show-artboard-names" -msgstr "Показать имена кадров" +msgstr "Показать имена досок" + +#: src/app/main/ui/workspace/main_menu.cljs:427 +msgid "workspace.header.menu.show-comments" +msgstr "Показать комментарии" #: src/app/main/ui/workspace/main_menu.cljs:402 msgid "workspace.header.menu.show-guides" @@ -4406,14 +6563,26 @@ msgstr "Переключить на тёмную тему" msgid "workspace.header.menu.toggle-light-theme" msgstr "Переключить на светлую тему" +#: src/app/main/ui/workspace/main_menu.cljs:329 +msgid "workspace.header.menu.toggle-system-theme" +msgstr "На системную тему" + #: src/app/main/ui/workspace/main_menu.cljs:553 msgid "workspace.header.menu.undo" msgstr "Отменить" +#: src/app/main/ui/workspace/main_menu.cljs:414 +msgid "workspace.header.menu.unlock-guides" +msgstr "Открепить направляющие" + #: src/app/main/ui/viewer/header.cljs:98, src/app/main/ui/workspace/right_header.cljs:96 msgid "workspace.header.reset-zoom" msgstr "Сброс" +#: src/app/main/ui/workspace/left_header.cljs:125 +msgid "workspace.header.retrying" +msgstr "Пробуем сохранить" + #: src/app/main/ui/workspace/left_header.cljs:126 msgid "workspace.header.save-error" msgstr "Ошибка при сохранении" @@ -4426,6 +6595,10 @@ msgstr "Сохранено" msgid "workspace.header.saving" msgstr "Сохранение" +#: src/app/main/ui/workspace/right_header.cljs:241 +msgid "workspace.header.share" +msgstr "Поделиться" + #: src/app/main/ui/workspace/right_header.cljs:246 msgid "workspace.header.viewer" msgstr "Открыть предпросмотр (%s)" @@ -4462,10 +6635,34 @@ msgstr "Изменить сетку" msgid "workspace.layout-grid.editor.options.exit" msgstr "Выход" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:583, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:592, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:598 +msgid "workspace.layout-grid.editor.padding.bottom" +msgstr "Отступ снизу" + #: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:668, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:669 msgid "workspace.layout-grid.editor.padding.expand" msgstr "Настроить внутренний отступ по отдельности" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:419, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:430, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:436 +msgid "workspace.layout-grid.editor.padding.horizontal" +msgstr "Отступ по горизонтали" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:617, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:626, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:632 +msgid "workspace.layout-grid.editor.padding.left" +msgstr "Отступ слева" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:550, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:559, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:565 +msgid "workspace.layout-grid.editor.padding.right" +msgstr "Отступ справа" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:516, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:525, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:531 +msgid "workspace.layout-grid.editor.padding.top" +msgstr "Отступ сверху" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:383, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:394, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:400 +msgid "workspace.layout-grid.editor.padding.vertical" +msgstr "Отступ по вертикали" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Редактирование сетки" @@ -4488,10 +6685,18 @@ msgid_plural "workspace.libraries.colors" msgstr[0] "1 цвет" msgstr[1] "%s цветов" +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:356 +msgid "workspace.libraries.colors.add-library-color" +msgstr "Добавить цвет в библиотеку" + #: src/app/main/ui/workspace/color_palette.cljs:203, src/app/main/ui/workspace/colorpicker/libraries.cljs:374, src/app/main/ui/workspace/colorpicker/libraries.cljs:405 msgid "workspace.libraries.colors.empty-palette" msgstr "В вашей библиотеке ещё нет цветовых стилей" +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:373, src/app/main/ui/workspace/colorpicker/libraries.cljs:389 +msgid "workspace.libraries.colors.empty-recent-colors" +msgstr "Недавних цветов пока нет" + #: src/app/main/ui/workspace/text_palette.cljs:158 msgid "workspace.libraries.colors.empty-typography-palette" msgstr "В вашей библиотеке ещё нет стилей типографики" @@ -4512,16 +6717,58 @@ msgstr "RGBA" msgid "workspace.libraries.colors.save-color" msgstr "Сохранить стиль цвета" +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:338 +msgid "workspace.libraries.colors.toggle-color-palette" +msgstr "Показать/скрыть палитру" + #: src/app/main/ui/workspace/libraries.cljs:120, src/app/main/ui/workspace/libraries.cljs:149, src/app/main/ui/workspace/libraries.cljs:191 msgid "workspace.libraries.components" msgid_plural "workspace.libraries.components" msgstr[0] "1 компонент" msgstr[1] "%s компонентов" +#: src/app/main/ui/workspace/libraries.cljs:842 +msgid "workspace.libraries.connected-libraries" +msgstr "Подключённая библиотека" + +#: src/app/main/ui/workspace/libraries.cljs:183 +msgid "workspace.libraries.connected-through" +msgstr "Подключена через" + +#: src/app/main/ui/workspace/libraries.cljs:454 +msgid "workspace.libraries.connected-to" +msgstr "Подключена к" + +#: src/app/main/ui/workspace/libraries.cljs:520, src/app/main/ui/workspace/libraries.cljs:644 +msgid "workspace.libraries.empty.add-some" +msgstr "Или добавьте что-нибудь для пробы:" + +#: src/app/main/ui/workspace/libraries.cljs:812 +msgid "workspace.libraries.empty.no-assets" +msgstr "Ресурсов пока нет" + +#: src/app/main/ui/workspace/libraries.cljs:895 +msgid "workspace.libraries.empty.no-connected-libraries" +msgstr "Подключённых библиотек пока нет." + +#: src/app/main/ui/workspace/libraries.cljs:514, src/app/main/ui/workspace/libraries.cljs:638 +msgid "workspace.libraries.empty.no-libraries" +msgstr "В команде нет общих библиотек, поищите" + +#: src/app/main/ui/workspace/libraries.cljs:518, src/app/main/ui/workspace/libraries.cljs:642 +msgid "workspace.libraries.empty.some-templates" +msgstr "что-нибудь из шаблонов" + #: src/app/main/ui/workspace/libraries.cljs:424, src/app/main/ui/workspace/libraries.cljs:806 msgid "workspace.libraries.file-library" msgstr "Библиотека файлов" +#: src/app/main/ui/workspace/libraries.cljs:123, src/app/main/ui/workspace/libraries.cljs:152, src/app/main/ui/workspace/libraries.cljs:195 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "1 изображение" +msgstr[1] "%s изображений" + #: src/app/main/ui/workspace/libraries.cljs:418 msgid "workspace.libraries.in-this-file" msgstr "БИБЛИОТЕКИ В ЭТОМ ФАЙЛЕ" @@ -4550,14 +6797,70 @@ msgstr "Совпадений для \"%s\" не найдено" msgid "workspace.libraries.search-shared-libraries" msgstr "Искать общие библиотеки" +#: src/app/main/ui/workspace/libraries.cljs:827, src/app/main/ui/workspace/libraries.cljs:874 +msgid "workspace.libraries.set-as-tokens-source" +msgstr "Сделать источником токенов" + #: src/app/main/ui/workspace/libraries.cljs:478, src/app/main/ui/workspace/libraries.cljs:595 msgid "workspace.libraries.shared-libraries" msgstr "ОБЩИЕ БИБЛИОТЕКИ" +#: src/app/main/ui/workspace/libraries.cljs:621 +msgid "workspace.libraries.shared-library-added" +msgstr "Добавлена" + #: src/app/main/ui/workspace/libraries.cljs:502, src/app/main/ui/workspace/libraries.cljs:626 msgid "workspace.libraries.shared-library-btn" msgstr "Подключить библиотеку" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:552 +msgid "workspace.libraries.text.mixed-tokens" +msgstr "Разные токены" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:547 +msgid "workspace.libraries.text.mixed-tokens-and-assets" +msgstr "Токены и ресурсы" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:567 +msgid "workspace.libraries.text.mixed-typography" +msgstr "Разные ресурсы" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:569 +msgid "workspace.libraries.text.multiple-assets-tooltip" +msgstr "Отвязать все ресурсы" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:554 +msgid "workspace.libraries.text.multiple-token-tooltip" +msgstr "Отвязать все токены" + +#: src/app/main/ui/workspace/libraries.cljs:167, src/app/main/ui/workspace/libraries.cljs:211 +msgid "workspace.libraries.token-sets" +msgid_plural "workspace.libraries.token-sets" +msgstr[0] "1 набор" +msgstr[1] "%s наборов" + +#: src/app/main/ui/workspace/libraries.cljs:170, src/app/main/ui/workspace/libraries.cljs:215 +msgid "workspace.libraries.token-themes" +msgid_plural "workspace.libraries.token-themes" +msgstr[0] "1 тема" +msgstr[1] "%s тем" + +#: src/app/main/ui/workspace/libraries.cljs:164, src/app/main/ui/workspace/libraries.cljs:207 +msgid "workspace.libraries.tokens" +msgid_plural "workspace.libraries.tokens" +msgstr[0] "1 токен" +msgstr[1] "%s токенов" + +#: src/app/main/ui/workspace/libraries.cljs:820, src/app/main/ui/workspace/libraries.cljs:866 +msgid "workspace.libraries.tokens-source" +msgstr "Источник токенов" + +#: src/app/main/ui/workspace/libraries.cljs:129, src/app/main/ui/workspace/libraries.cljs:158, src/app/main/ui/workspace/libraries.cljs:203 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "1 типографика" +msgstr[1] "%s типографик" + #: src/app/main/ui/workspace/libraries.cljs:469, src/app/main/ui/workspace/libraries.cljs:885 msgid "workspace.libraries.unlink-library-btn" msgstr "Отключить библиотеку" @@ -4574,10 +6877,44 @@ msgstr "смотреть все изменения" msgid "workspace.libraries.updates" msgstr "ОБНОВЛЕНИЯ" +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:61 +msgid "workspace.notification-pill.detail" +msgstr "Подробности" + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Нажмите кнопку \"+\" для добавления интерактива." +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:273 +msgid "workspace.options.blur-effects-options.title.group" +msgstr "Размытие группы" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:272 +msgid "workspace.options.blur-effects-options.title.multiple" +msgstr "Размытие выбранного" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:287 +msgid "workspace.options.blur-options.add-blur" +msgstr "Добавить размытие" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:99, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:109 +msgid "workspace.options.blur-options.background-blur" +msgstr "Фоновое размытие" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:144 +msgid "workspace.options.blur-options.blur-type-select" +msgstr "Тип размытия" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:153 +msgid "workspace.options.blur-options.disabled-blur-label" +msgstr "" +"Фоновое размытие — только в новом рендере. Включите его в меню " +"«Предпочтения»." + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:98, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:112 +msgid "workspace.options.blur-options.layer-blur" +msgstr "Размытие слоя" + #: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:169, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:301 msgid "workspace.options.blur-options.remove-blur" msgstr "Удалить размытие" @@ -4590,10 +6927,26 @@ msgstr "Групповое размытие" msgid "workspace.options.blur-options.title.multiple" msgstr "Выборка размытия" +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:160 +msgid "workspace.options.blur-options.toggle-blur" +msgstr "Показать/скрыть размытие" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:138 +msgid "workspace.options.blur-options.toggle-more-options" +msgstr "Показать/скрыть ещё" + +#: src/app/main/ui/exports/assets.cljs:332 +msgid "workspace.options.cancel-export" +msgstr "Отмена" + #: src/app/main/ui/workspace/sidebar/options/page.cljs:78, src/app/main/ui/workspace/sidebar/options/page.cljs:86 msgid "workspace.options.canvas-background" msgstr "Фон холста" +#: src/app/main/ui/workspace/sidebar/options/page.cljs:75 +msgid "workspace.options.canvas-background-section" +msgstr "Фон холста" + #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:722 msgid "workspace.options.clip-content" msgstr "Зажать содержимое" @@ -4630,6 +6983,118 @@ msgstr "Заменить компонент" msgid "workspace.options.component.swap.empty" msgstr "В этой библиотеке ещё нет ресурсов" +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1061 +msgid "workspace.options.component.unlinked" +msgstr "Отвязан" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:581 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "Найти конфликтующие варианты" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:578 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"У компонента конфликтующие варианты. У каждого сочетания свойств должны " +"быть свои значения." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1336 +msgid "workspace.options.component.variant.duplicated.group.locate" +msgstr "Найти дублирующиеся варианты" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1333 +msgid "workspace.options.component.variant.duplicated.group.title" +msgstr "У части вариантов совпадают свойства и значения" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:266 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"У этих вариантов совпадают свойства и значения. Подправьте значения, чтобы " +"их можно было найти." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:263 +msgid "workspace.options.component.variant.duplicated.single.one" +msgstr "" +"У варианта такие же свойства и значения, как у другого. Подправьте " +"значения, чтобы его можно было найти." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:269 +msgid "workspace.options.component.variant.duplicated.single.some" +msgstr "" +"У части вариантов совпадают свойства и значения. Подправьте значения, чтобы " +"их можно было найти." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:568 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"У компонента варианты с неверными именами. Каждый вариант должен следовать " +"структуре." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1326 +msgid "workspace.options.component.variant.malformed.group.locate" +msgstr "Найти неверные варианты" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1323 +msgid "workspace.options.component.variant.malformed.group.title" +msgstr "У части вариантов неверные имена" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:571 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "Найти неверные варианты" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:250 +msgid "workspace.options.component.variant.malformed.single.all" +msgstr "У этих вариантов неверные имена." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:247 +msgid "workspace.options.component.variant.malformed.single.one" +msgstr "У варианта неверное имя." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:253 +msgid "workspace.options.component.variant.malformed.single.some" +msgstr "У части вариантов неверные имена." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:436 +msgid "workspace.options.component.variant.malformed.structure.example" +msgstr "[свойство]=[значение], [свойство]=[значение]" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:434 +msgid "workspace.options.component.variant.malformed.structure.title" +msgstr "Попробуйте такую структуру:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "Чтобы не терять правки при смене вариантов, Penpot связывает слои, которые:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Смена любого пункта (например, переименование или группировка слоя) рвёт " +"связь, но отмена вернёт её." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "Носят одно имя." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "Одного типа." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "Прямоугольник, эллипс, контуры и булевы операции — один тип." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "На одном уровне вложенности." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "Группы, доски и макеты считаются равными." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1040, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1284, src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:47 +msgid "workspace.options.component.variants-help-modal.title" +msgstr "Как варианты держат связь" + #: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:189 msgid "workspace.options.constraints" msgstr "Ограничения" @@ -4670,6 +7135,10 @@ msgstr "Верх" msgid "workspace.options.constraints.topbottom" msgstr "Верх и низ" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:527 +msgid "workspace.options.convert-to-typography" +msgstr "Создать стиль типографики" + #: src/app/main/ui/workspace/sidebar/options.cljs:238 msgid "workspace.options.design" msgstr "Дизайн" @@ -4688,6 +7157,10 @@ msgid_plural "workspace.options.export-object" msgstr[0] "Экспорт 1 элемента" msgstr[1] "Экспорт %s элементов" +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:216 +msgid "workspace.options.export.add-export" +msgstr "Добавить экспорт" + #: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:228, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:263 msgid "workspace.options.export.remove-export" msgstr "Удалить экспорт" @@ -4696,6 +7169,18 @@ msgstr "Удалить экспорт" msgid "workspace.options.export.suffix" msgstr "Суффикс" +#: src/app/main/ui/exports/assets.cljs:267 +msgid "workspace.options.exporting-busy" +msgstr "Служба экспорта занята, попробуйте позже" + +#: src/app/main/ui/exports/assets.cljs:270 +msgid "workspace.options.exporting-cancelled" +msgstr "Экспорт отменён" + +#: src/app/main/ui/exports/assets.cljs:269 +msgid "workspace.options.exporting-cancelling" +msgstr "Отмена…" + #: src/app/main/ui/exports/assets.cljs:272 msgid "workspace.options.exporting-complete" msgstr "Экспорт завершён" @@ -4712,14 +7197,30 @@ msgstr "Экспорт не удался" msgid "workspace.options.exporting-object-slow" msgstr "Неожиданно медленный экспорт" +#: src/app/main/ui/exports/assets.cljs:271 +msgid "workspace.options.exporting-queued" +msgstr "Ожидание…" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:238 msgid "workspace.options.fill" msgstr "Заливка" +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:210 +msgid "workspace.options.fill.add-fill" +msgstr "Добавить заливку" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:225 msgid "workspace.options.fill.remove-fill" msgstr "Удалить заливку" +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:200 +msgid "workspace.options.fill.section" +msgstr "Заливка" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:553 +msgid "workspace.options.fit-content" +msgstr "Подогнать доску под содержимое" + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:700 msgid "workspace.options.flows.add-flow-start" msgstr "Добавить начало потока" @@ -4736,6 +7237,10 @@ msgstr "Начало потока" msgid "workspace.options.flows.flow-starts" msgstr "Начала потоков" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:526 +msgid "workspace.options.font-not-available" +msgstr "Шрифта (%s) больше нет. Загрузите его снова, чтобы создать стиль типографики" + #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:34 msgid "workspace.options.grid.auto" msgstr "Авто" @@ -4812,10 +7317,22 @@ msgstr "Заливка для группы" msgid "workspace.options.group-stroke" msgstr "Обводка для группы" +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:366 +msgid "workspace.options.guides.add-guide" +msgstr "Добавить направляющую" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:235 +msgid "workspace.options.guides.remove-guide" +msgstr "Убрать направляющую" + #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:363 msgid "workspace.options.guides.title" msgstr "Направляющие" +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:231 +msgid "workspace.options.guides.toggle-guide" +msgstr "Показать/скрыть направляющую" + #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:583, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:602 msgid "workspace.options.height" msgstr "Высота" @@ -5032,6 +7549,10 @@ msgstr "При нажатии" msgid "workspace.options.interactions" msgstr "Интерактив" +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:746 +msgid "workspace.options.interactions.add-interaction" +msgstr "Добавить взаимодействие" + #: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:203 msgid "workspace.options.layer-options.blend-mode.color" msgstr "Цвет" @@ -5096,6 +7617,14 @@ msgstr "Экран" msgid "workspace.options.layer-options.blend-mode.soft-light" msgstr "Мягкий свет" +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:223, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:285 +msgid "workspace.options.layer-options.layer-section" +msgstr "Слой" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:256, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:263, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:311, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:317 +msgid "workspace.options.layer-options.toggle-layer" +msgstr "Показать/скрыть слой" + #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:695, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:702 msgid "workspace.options.layout-item.layout-item-max-h" msgstr "Макс. высота" @@ -5120,10 +7649,22 @@ msgstr "Больше цветов" msgid "workspace.options.more-lib-colors" msgstr "Больше цветов библиотеки" +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:268 +msgid "workspace.options.more-token-colors" +msgstr "Ещё токены цвета" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:117, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:518 +msgid "workspace.options.no-size-preset-results" +msgstr "Нет подходящего пресета размера" + #: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:240, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:297 msgid "workspace.options.opacity" msgstr "Непрозрачность" +#: src/app/main/ui/workspace/sidebar/options/page.cljs:99, src/app/main/ui/workspace/sidebar/options/page.cljs:106 +msgid "workspace.options.pixel-grid-color" +msgstr "Цвет пиксельной сетки" + #: src/app/main/ui/workspace/sidebar/options.cljs:240 msgid "workspace.options.prototype" msgstr "Прототип" @@ -5148,6 +7689,18 @@ msgstr "Сверху слева" msgid "workspace.options.radius-top-right" msgstr "Сверху справа" +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:205, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:346 +msgid "workspace.options.radius.hide-all-corners" +msgstr "Свернуть радиусы по отдельности" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:169, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:287 +msgid "workspace.options.radius.radius-section" +msgstr "Скругление" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:206, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:347 +msgid "workspace.options.radius.show-single-corners" +msgstr "Показать радиусы по отдельности" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:464 msgid "workspace.options.recent-fonts" msgstr "Последние" @@ -5164,6 +7717,10 @@ msgstr "Вращение" msgid "workspace.options.search-font" msgstr "Искать шрифт" +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:111, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:512 +msgid "workspace.options.search-size-preset" +msgstr "Найти пресет размера" + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:786 msgid "workspace.options.select-a-shape" msgstr "Выберите фигуру, кадр или группу для соединения с другим кадром." @@ -5172,6 +7729,10 @@ msgstr "Выберите фигуру, кадр или группу для со msgid "workspace.options.selection-color" msgstr "Выбранные цвета" +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:200 +msgid "workspace.options.selection-color.section" +msgstr "Выбранные цвета" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:106 msgid "workspace.options.selection-fill" msgstr "Заливка выбранного" @@ -5180,6 +7741,10 @@ msgstr "Заливка выбранного" msgid "workspace.options.selection-stroke" msgstr "Обводка выбранного" +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:154 +msgid "workspace.options.shadow-options.add-shadow" +msgstr "Добавить тень" + #: src/app/main/ui/inspect/attributes/shadow.cljs:47, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:180, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:182 msgid "workspace.options.shadow-options.blur" msgstr "Размытие" @@ -5224,6 +7789,10 @@ msgstr "Групповая тень" msgid "workspace.options.shadow-options.title.multiple" msgstr "Выборочные тени" +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:156 +msgid "workspace.options.shadow-options.toggle-shadow" +msgstr "Показать/скрыть тень" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:265 msgid "workspace.options.show-fill-on-export" msgstr "Показать в экспорте" @@ -5240,6 +7809,14 @@ msgstr "Размер" msgid "workspace.options.size-presets" msgstr "Предустановки размеров" +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:617 +msgid "workspace.options.size.lock" +msgstr "Запереть пропорции" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:617 +msgid "workspace.options.size.unlock" +msgstr "Отпереть пропорции" + #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:334 msgid "workspace.options.stroke-cap.circle-marker-short" msgstr "Круг" @@ -5276,10 +7853,38 @@ msgstr "Треугольник" msgid "workspace.options.stroke-color" msgstr "Цвет обводки" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:548 +msgid "workspace.options.stroke-dash" +msgstr "Штрих" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:558 +msgid "workspace.options.stroke-gap" +msgstr "Пробел" + #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:405, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:443 msgid "workspace.options.stroke-width" msgstr "Толщина обводки" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:518 +msgid "workspace.options.stroke-width-bottom" +msgstr "Низ (конец блока)" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:531 +msgid "workspace.options.stroke-width-left" +msgstr "Лево (начало строки)" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:505 +msgid "workspace.options.stroke-width-right" +msgstr "Право (конец строки)" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:492 +msgid "workspace.options.stroke-width-top" +msgstr "Верх (начало блока)" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:267 +msgid "workspace.options.stroke.add-stroke" +msgstr "Добавить цвет обводки" + #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:238 msgid "workspace.options.stroke.center" msgstr "Центр" @@ -5304,6 +7909,14 @@ msgstr "Смешаный" msgid "workspace.options.stroke.outer" msgstr "Наружу" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:150 +msgid "workspace.options.stroke.per-side" +msgstr "Обводка по сторонам" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:149 +msgid "workspace.options.stroke.per-side-disabled" +msgstr "Обводка по сторонам — только в новом рендере. Переключите рендер." + #: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:282, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:389 msgid "workspace.options.stroke.remove-stroke" msgstr "Удалить обводку" @@ -5312,6 +7925,10 @@ msgstr "Удалить обводку" msgid "workspace.options.stroke.solid" msgstr "Сплошной" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:384 +msgid "workspace.options.stroke.toggle-stroke" +msgstr "Показать/скрыть обводку" + #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:135 msgid "workspace.options.text-options.align-bottom" msgstr "Выровнять низ" @@ -5372,6 +7989,10 @@ msgstr "Выравнивание по левому краю" msgid "workspace.options.text-options.text-align-right" msgstr "Выравнивание по правому краю" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:509 +msgid "workspace.options.text-options.text-section" +msgstr "Текст" + #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:380 msgid "workspace.options.text-options.title" msgstr "Текст" @@ -5408,6 +8029,22 @@ msgstr "Ось Y" msgid "workspace.path.actions.draw-nodes" msgstr "Показать узлы (%s)" +#: src/app/main/ui/workspace/viewport/path_actions.cljs:165 +msgid "workspace.path.actions.handler-aligned" +msgstr "Выровненный" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:169 +msgid "workspace.path.actions.handler-independent" +msgstr "Независимый" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:161 +msgid "workspace.path.actions.handler-mirror" +msgstr "Равный" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:148 +msgid "workspace.path.actions.handler-type" +msgstr "Поведение маркера" + #: src/app/main/ui/workspace/viewport/path_actions.cljs:288 msgid "workspace.path.actions.join-nodes" msgstr "Соединить узлы (%s)" @@ -5453,6 +8090,14 @@ msgstr "Плагины ещё не установлены" msgid "workspace.plugins.error.manifest" msgstr "Манифест плагина неверен." +#: src/app/main/data/plugins.cljs:129, src/app/main/ui/workspace/main_menu.cljs:811, src/app/main/ui/workspace/plugins.cljs:97 +msgid "workspace.plugins.error.need-editor" +msgstr "Чтобы пользоваться плагином, нужна роль редактора" + +#: src/app/main/data/plugins.cljs:158 +msgid "workspace.plugins.error.unreachable" +msgstr "До адреса плагина не достучаться." + #: src/app/main/ui/workspace/plugins.cljs:205 msgid "workspace.plugins.error.url" msgstr "Плагин не существует или его URL-адрес неверен." @@ -5473,6 +8118,68 @@ msgstr "Менеджер плагинов" msgid "workspace.plugins.menu.title" msgstr "Плагины" +#: src/app/main/ui/workspace/plugins.cljs:424 +msgid "workspace.plugins.permissions-update.title" +msgstr "ОБНОВИТЕ ЭТОТ ПЛАГИН" + +#: src/app/main/ui/workspace/plugins.cljs:428 +msgid "workspace.plugins.permissions-update.warning" +msgstr "Плагин изменился с прошлого открытия. Теперь он просит доступ к:" + +#: src/app/main/ui/workspace/plugins.cljs:297 +msgid "workspace.plugins.permissions.allow-download" +msgstr "Начинать скачивание файлов." + +#: src/app/main/ui/workspace/plugins.cljs:304 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Хранить данные в браузере." + +#: src/app/main/ui/workspace/plugins.cljs:317 +msgid "workspace.plugins.permissions.clipboard-read" +msgstr "Читать содержимое буфера обмена." + +#: src/app/main/ui/workspace/plugins.cljs:311 +msgid "workspace.plugins.permissions.clipboard-write" +msgstr "Читать буфер обмена и писать в него." + +#: src/app/main/ui/workspace/plugins.cljs:290 +msgid "workspace.plugins.permissions.comment-read" +msgstr "Читать Ваши комментарии и ответы." + +#: src/app/main/ui/workspace/plugins.cljs:284 +msgid "workspace.plugins.permissions.comment-write" +msgstr "Читать и менять Ваши комментарии и отвечать от Вашего имени." + +#: src/app/main/ui/workspace/plugins.cljs:257 +msgid "workspace.plugins.permissions.content-read" +msgstr "Читать содержимое доступных Вам файлов." + +#: src/app/main/ui/workspace/plugins.cljs:251 +msgid "workspace.plugins.permissions.content-write" +msgstr "Читать и менять содержимое доступных Вам файлов." + +#: src/app/main/ui/workspace/plugins.cljs:366 +msgid "workspace.plugins.permissions.disclaimer" +msgstr "" +"Учтите: плагин сделал посторонний, убедитесь, что ему доверяете. Нам важны " +"Ваши данные и безопасность. Сомневаетесь — напишите в поддержку." + +#: src/app/main/ui/workspace/plugins.cljs:277 +msgid "workspace.plugins.permissions.library-read" +msgstr "Читать Ваши библиотеки и ресурсы." + +#: src/app/main/ui/workspace/plugins.cljs:271 +msgid "workspace.plugins.permissions.library-write" +msgstr "Читать и менять Ваши библиотеки и ресурсы." + +#: src/app/main/ui/workspace/plugins.cljs:359 +msgid "workspace.plugins.permissions.title" +msgstr "ПЛАГИН «%s» ПРОСИТ ДОСТУП К:" + +#: src/app/main/ui/workspace/plugins.cljs:264 +msgid "workspace.plugins.permissions.user-read" +msgstr "Читать профиль текущего пользователя." + #: src/app/main/ui/workspace/plugins.cljs:227 msgid "workspace.plugins.plugin-list-link" msgstr "Список плагинов" @@ -5489,6 +8196,24 @@ msgstr "Введите URL-адрес плагина" msgid "workspace.plugins.title" msgstr "Плагины" +#: src/app/main/ui/workspace/plugins.cljs:494 +msgid "workspace.plugins.try-out.cancel" +msgstr "НЕ СЕЙЧАС" + +#: src/app/main/ui/workspace/plugins.cljs:487 +msgid "workspace.plugins.try-out.message" +msgstr "" +"Заглянуть? Откроется в новом черновике Вашей команды. (Если нет — " +"установленные плагины лежат в любом файле.)" + +#: src/app/main/ui/workspace/plugins.cljs:483 +msgid "workspace.plugins.try-out.title" +msgstr "ПЛАГИН «%s» УСТАНОВЛЕН!" + +#: src/app/main/ui/workspace/plugins.cljs:500 +msgid "workspace.plugins.try-out.try" +msgstr "ПОПРОБОВАТЬ ПЛАГИН" + #: src/app/main/ui/workspace/context_menu.cljs:659 msgid "workspace.shape.menu.add-flex" msgstr "Добавить макет гибкости" @@ -5497,6 +8222,18 @@ msgstr "Добавить макет гибкости" msgid "workspace.shape.menu.add-grid" msgstr "Добавить макет сетки" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1304, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1318 +msgid "workspace.shape.menu.add-layout" +msgstr "Добавить макет" + +#: src/app/main/ui/workspace/context_menu.cljs:719, src/app/main/ui/workspace/sidebar/assets/common.cljs:585, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1046, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1214, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1288 +msgid "workspace.shape.menu.add-variant" +msgstr "Создать вариант" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:589, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1068, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1216, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1302 +msgid "workspace.shape.menu.add-variant-property" +msgstr "Добавить свойство" + #: src/app/main/ui/workspace/context_menu.cljs:327 msgid "workspace.shape.menu.back" msgstr "На задний план" @@ -5505,6 +8242,14 @@ msgstr "На задний план" msgid "workspace.shape.menu.backward" msgstr "Перенести назад" +#: src/app/main/ui/workspace/context_menu.cljs:746 +msgid "workspace.shape.menu.clear-guides" +msgstr "Убрать направляющие доски" + +#: src/app/main/ui/workspace/context_menu.cljs:727, src/app/main/ui/workspace/sidebar/assets/components.cljs:663, src/app/main/ui/workspace/sidebar/assets/groups.cljs:80, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1101 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Объединить как варианты" + #: src/app/main/ui/workspace/sidebar/assets/components.cljs:665 msgid "workspace.shape.menu.combine-as-variants-error" msgstr "Компоненты должны находиться на одной странице" @@ -5513,6 +8258,22 @@ msgstr "Компоненты должны находиться на одной msgid "workspace.shape.menu.copy" msgstr "Скопировать" +#: src/app/main/ui/workspace/context_menu.cljs:259 +msgid "workspace.shape.menu.copy-as-image" +msgstr "Скопировать как картинку" + +#: src/app/main/ui/workspace/context_menu.cljs:251 +msgid "workspace.shape.menu.copy-css" +msgstr "Скопировать как CSS" + +#: src/app/main/ui/workspace/context_menu.cljs:253 +msgid "workspace.shape.menu.copy-css-nested" +msgstr "Скопировать как CSS (вложенные слои)" + +#: src/app/main/ui/workspace/context_menu.cljs:226 +msgid "workspace.shape.menu.copy-id" +msgstr "Скопировать id" + #: src/app/main/ui/workspace/context_menu.cljs:232 msgid "workspace.shape.menu.copy-link" msgstr "Скопировать ссылку" @@ -5521,13 +8282,25 @@ msgstr "Скопировать ссылку" msgid "workspace.shape.menu.copy-paste-as" msgstr "Скопировать/Вставить как..." +#: src/app/main/ui/workspace/context_menu.cljs:268 +msgid "workspace.shape.menu.copy-props" +msgstr "Скопировать свойства" + +#: src/app/main/ui/workspace/context_menu.cljs:255 +msgid "workspace.shape.menu.copy-svg" +msgstr "Скопировать как SVG" + +#: src/app/main/ui/workspace/context_menu.cljs:265 +msgid "workspace.shape.menu.copy-text" +msgstr "Скопировать как текст" + #: src/app/main/ui/workspace/sidebar/assets/common.cljs:561 msgid "workspace.shape.menu.create-annotation" msgstr "Создать аннотацию" #: src/app/main/ui/workspace/context_menu.cljs:444 msgid "workspace.shape.menu.create-artboard-from-selection" -msgstr "Выделенное в кадр" +msgstr "Выделенное в доску" #: src/app/main/ui/workspace/context_menu.cljs:697 msgid "workspace.shape.menu.create-component" @@ -5625,6 +8398,10 @@ msgstr "Маска" msgid "workspace.shape.menu.paste" msgstr "Вставить" +#: src/app/main/ui/workspace/context_menu.cljs:273 +msgid "workspace.shape.menu.paste-props" +msgstr "Вставить свойства" + #: src/app/main/ui/workspace/context_menu.cljs:530 msgid "workspace.shape.menu.path" msgstr "Контур" @@ -5637,6 +8414,18 @@ msgstr "Удалить макет гибкости" msgid "workspace.shape.menu.remove-grid" msgstr "Удалить макет сетки" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1312 +msgid "workspace.shape.menu.remove-layout" +msgstr "Убрать макет" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1152 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "Убрать свойство" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1151 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "У варианта должно быть хотя бы одно свойство" + #: src/app/main/ui/workspace/context_menu.cljs:384 msgid "workspace.shape.menu.rename" msgstr "Переименовать" @@ -5649,6 +8438,10 @@ msgstr "Сбросить переопределения" msgid "workspace.shape.menu.restore-main" msgstr "Восстановить основной компонент" +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:575 +msgid "workspace.shape.menu.restore-variant" +msgstr "Восстановить вариант" + #: src/app/main/ui/workspace/context_menu.cljs:305 msgid "workspace.shape.menu.select-layer" msgstr "Выбрать слой" @@ -5665,6 +8458,10 @@ msgstr "Показать на панели ресурсов" msgid "workspace.shape.menu.show-main" msgstr "Показать основной компонент" +#: src/app/main/ui/workspace/context_menu.cljs:524 +msgid "workspace.shape.menu.stroke-to-path" +msgstr "Обводку в контур" + #: src/app/main/ui/workspace/context_menu.cljs:366 msgid "workspace.shape.menu.thumbnail-remove" msgstr "Удалить миниатюру" @@ -5713,6 +8510,10 @@ msgstr "Слои" msgid "workspace.sidebar.layers.components" msgstr "Компоненты" +#: src/app/main/ui/workspace/sidebar/layers.cljs:585 +msgid "workspace.sidebar.layers.filter" +msgstr "Фильтр" + #: src/app/main/ui/workspace/sidebar/layers.cljs:649, src/app/main/ui/workspace/sidebar/layers.cljs:680 msgid "workspace.sidebar.layers.frames" msgstr "Доски" @@ -5729,10 +8530,38 @@ msgstr "Изображения" msgid "workspace.sidebar.layers.masks" msgstr "Маски" +#: src/app/main/ui/workspace/sidebar/layers.cljs:617 +msgid "workspace.sidebar.layers.no-matches" +msgstr "Совпадений нет" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:636 +msgid "workspace.sidebar.layers.replace" +msgstr "Заменить" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:642 +msgid "workspace.sidebar.layers.replace-all" +msgstr "Заменить всё" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:603 +msgid "workspace.sidebar.layers.replace-placeholder" +msgstr "Заменить на…" + #: src/app/main/ui/workspace/sidebar/layers.cljs:581 msgid "workspace.sidebar.layers.search" msgstr "Искать слои" +#: src/app/main/ui/workspace/sidebar/layers.cljs:591 +msgid "workspace.sidebar.layers.search-and-replace" +msgstr "Найти и заменить" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:622 +msgid "workspace.sidebar.layers.search-scope-canvas" +msgstr "Текст" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:626 +msgid "workspace.sidebar.layers.search-scope-layers" +msgstr "Имена слоёв" + #: src/app/main/ui/workspace/sidebar/layers.cljs:655, src/app/main/ui/workspace/sidebar/layers.cljs:752 msgid "workspace.sidebar.layers.shapes" msgstr "Формы" @@ -5757,26 +8586,546 @@ msgstr "Добавить страницу" msgid "workspace.sitemap" msgstr "Карта сайта" +#: src/app/main/data/workspace/comments.cljs:49 +msgid "workspace.toast.comments-hidden" +msgstr "Комментарии скрыты" + +#: src/app/main/data/workspace/comments.cljs:50 +msgid "workspace.toast.comments-visible" +msgstr "Комментарии видны" + #: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:112 msgid "workspace.tokens.active-themes" msgstr "Активных тем: %s" +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:149, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:360 +msgid "workspace.tokens.add-new-theme" +msgstr "Добавить тему" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:106 +msgid "workspace.tokens.add-set" +msgstr "Добавить набор" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:62 +msgid "workspace.tokens.add-set-to-group" +msgstr "Добавить набор в группу" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:201, src/app/main/ui/workspace/tokens/management/group.cljs:186 +msgid "workspace.tokens.add-token" +msgstr "Добавить токен: %s" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:156 +msgid "workspace.tokens.applied-to" +msgstr "Применено к" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:400 +msgid "workspace.tokens.axis" +msgstr "Ось" + #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:369 msgid "workspace.tokens.back-to-themes" msgstr "К списку тем" +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:88 +msgid "workspace.tokens.base-font-size" +msgstr "Базовый размер шрифта" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:42 +msgid "workspace.tokens.base-font-size.error" +msgstr "Базовый размер — в пикселях или без единиц." + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:98 +msgid "workspace.tokens.change-token-source" +msgstr "Сменить источник токенов" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:369, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:132 +msgid "workspace.tokens.color" +msgstr "Цвет" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:433 +msgid "workspace.tokens.copy-name" +msgstr "Скопировать путь токена" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 +msgid "workspace.tokens.create-new-theme" +msgstr "Создайте первую тему прямо сейчас." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, src/app/main/ui/workspace/tokens/themes.cljs:44 +msgid "workspace.tokens.create-one" +msgstr "Создать набор." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:293 +msgid "workspace.tokens.create-token" +msgstr "Создать токен %s" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:101 +msgid "workspace.tokens.current-tokens-source-tooltip" +msgstr "Это текущий источник токенов" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:437 +msgid "workspace.tokens.delete" +msgstr "Удалить токен" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:138 +msgid "workspace.tokens.delete-theme-title" +msgstr "Удалить тему" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:430 +msgid "workspace.tokens.duplicate" +msgstr "Дублировать токен" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:60 +msgid "workspace.tokens.duplicate-group" +msgstr "Дублировать группу токенов" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:395 +msgid "workspace.tokens.duplicate-suffix" +msgstr "копия" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:416 +msgid "workspace.tokens.edit" +msgstr "Править токен" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:359 +msgid "workspace.tokens.edit-theme-title" +msgstr "Править тему" + #: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:87 msgid "workspace.tokens.edit-themes" msgstr "Изменить темы" +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:292 +msgid "workspace.tokens.edit-token" +msgstr "Править токен %s" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:299 +msgid "workspace.tokens.enter-token-name" +msgstr "Введите имя токена %s" + +#: src/app/main/data/workspace/tokens/application.cljs:767, src/app/main/ui/workspace/tokens/management/group.cljs:164 +msgid "workspace.tokens.error-text-edition" +msgstr "Токены нельзя применить во время правки текста. Выберите текстовый слой." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:48 +msgid "workspace.tokens.export" +msgstr "Экспорт" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:124 +msgid "workspace.tokens.export-tokens" +msgstr "Экспорт токенов" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:117 +msgid "workspace.tokens.export.multiple-files" +msgstr "Несколько файлов" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:37 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "Экспортировать нечего: нет токенов, тем и наборов." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:34 +msgid "workspace.tokens.export.preview" +msgstr "Предпросмотр:" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:115 +msgid "workspace.tokens.export.single-file" +msgstr "Один файл" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Размер шрифта или {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:364 +msgid "workspace.tokens.font-variant-not-found" +msgstr "Не удалось задать насыщенность и стиль: этого стиля нет в текущем шрифте" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:60, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "Насыщенность (300, Bold Italic...) или {alias}" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:253 +msgid "workspace.tokens.gaps" +msgstr "Промежутки" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:93 +msgid "workspace.tokens.group-name" +msgstr "Имя группы" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:232 +msgid "workspace.tokens.import-button-prefix" +msgstr "Импорт %s" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "Папка" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:270 +msgid "workspace.tokens.import-menu-json-option" +msgstr "Один JSON-файл" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "ZIP-файл" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:240 +msgid "workspace.tokens.import-multiple-files" +msgstr "В нескольких файлах именами наборов служат имя и путь файла." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:239 +msgid "workspace.tokens.import-single-file" +msgstr "В одном JSON-файле ключи верхнего уровня — имена наборов токенов." + +#: src/app/main/ui/workspace/libraries.cljs:462, src/app/main/ui/workspace/libraries.cljs:878, src/app/main/ui/workspace/tokens/import/modal.cljs:236 +msgid "workspace.tokens.import-tokens" +msgstr "Импорт токенов" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:246, src/app/main/ui/workspace/tokens/import_from_library.cljs:81 +msgid "workspace.tokens.import-warning" +msgstr "Импорт токенов заменит текущие токены, наборы и темы." + +#: src/app/main/ui/workspace/tokens/management.cljs:81 +msgid "workspace.tokens.inactive-set" +msgstr "Неактивен" + +#: src/app/main/ui/workspace/tokens/management.cljs:73 +msgid "workspace.tokens.inactive-set-description" +msgstr "" +"Набор неактивен. Смените тему или активируйте набор, чтобы увидеть " +"изменения во вьюпорте" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:245, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:196 +msgid "workspace.tokens.individual-tokens" +msgstr "Использовать отдельные токены" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:203 +msgid "workspace.tokens.label.group" +msgstr "Группа" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 +msgid "workspace.tokens.label.group-placeholder" +msgstr "Добавьте группу (например, Mode)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:212 +msgid "workspace.tokens.label.theme" +msgstr "Тема" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:213 +msgid "workspace.tokens.label.theme-placeholder" +msgstr "Добавьте тему (например, Light)" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Межбуквенный интервал или {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "Высота строки (множитель, px, %) или {alias}" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:245 +msgid "workspace.tokens.margins" +msgstr "Внешние отступы" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:282 +msgid "workspace.tokens.max-size" +msgstr "Макс. размер" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:276 +msgid "workspace.tokens.min-size" +msgstr "Мин. размер" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:351 +msgid "workspace.tokens.missing-reference" +msgstr "Нет ссылки" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:137 +msgid "workspace.tokens.more-options" +msgstr "Щёлкните правой кнопкой — там настройки" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:133 +msgid "workspace.tokens.no-active-sets" +msgstr "Нет активных наборов" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:115 +msgid "workspace.tokens.no-active-theme" +msgstr "Нет активной темы" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:72 +msgid "workspace.tokens.no-permisions-set" +msgstr "Чтобы включать и отключать наборы, нужна роль редактора" + +#: src/app/main/ui/workspace/tokens/themes.cljs:54 +msgid "workspace.tokens.no-permission-themes" +msgstr "Чтобы пользоваться темами, нужна роль редактора" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:492 +msgid "workspace.tokens.no-sets-create" +msgstr "Наборы пока не заданы. Создайте первый." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:93, src/app/main/ui/workspace/tokens/sets/lists.cljs:99 +msgid "workspace.tokens.no-sets-yet" +msgstr "Наборов пока нет." + +#: src/app/main/ui/workspace/tokens/themes.cljs:40 +msgid "workspace.tokens.no-themes" +msgstr "Тем пока нет." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:53 +msgid "workspace.tokens.no-themes-currently" +msgstr "У Вас пока нет тем." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:113 +msgid "workspace.tokens.not-remap" +msgstr "Не переназначать" + +#: src/app/main/data/workspace/libraries.cljs:847 +msgid "workspace.tokens.notifications.source-sets-or-themes-updated" +msgstr "Наборы и темы источника токенов изменены" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:132 +msgid "workspace.tokens.num-active-sets" +msgstr "Активных наборов: %s" + +#: src/app/main/ui/workspace/colorpicker.cljs:438, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:67 +msgid "workspace.tokens.only-first-fill-or-stroke" +msgstr "Токены дизайна применяются только к первой заливке или обводке в списке." + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:104 +msgid "workspace.tokens.open-source-new-tab" +msgstr "Открыть источник токенов в новой вкладке" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:134 +msgid "workspace.tokens.original-value" +msgstr "Исходное значение: %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:229 +msgid "workspace.tokens.paddings" +msgstr "Внутренние отступы" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:362 +msgid "workspace.tokens.radius" +msgstr "Радиус" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:58, src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs:60, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:124, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:131, src/app/main/ui/workspace/tokens/management/token_pill.cljs:142 +msgid "workspace.tokens.ref-not-valid" +msgstr "Ссылка в {%s} неверна или отсутствует в активных наборах." + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Введите алиас типографики токена" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:208 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Введите алиас тени токена" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:117 +msgid "workspace.tokens.remap" +msgstr "Переназначить токены" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:102 +msgid "workspace.tokens.remap-node-references-title" +msgstr "Переименовать «%s» в «%s» и переназначить токены группы?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106 +msgid "workspace.tokens.remap-node-warning-effects" +msgstr "Обновятся все токены и ссылки со старым именем." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:101 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Переназначить токены с «%s» на «%s»?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105 +msgid "workspace.tokens.remap-token-warning-effects" +msgstr "Изменятся все слои и ссылки со старым именем токена." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:107 +msgid "workspace.tokens.remap-warning-time" +msgstr "Действие может занять время." + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:59 +msgid "workspace.tokens.rename-group" +msgstr "Переименовать группу токенов" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:68 +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Токены переименуются сами: %s.(суффикс).(токен)" + +#: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:61, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:89, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:127, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:296, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:489, src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs:298, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:189, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:324, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:259, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:381, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:505, src/app/main/ui/workspace/tokens/management/token_pill.cljs:135 +msgid "workspace.tokens.resolved-value" +msgstr "Итоговое значение: %s" + #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:249 msgid "workspace.tokens.save-theme" msgstr "Сохранить тему" +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:307, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:538 +msgid "workspace.tokens.search-by-token" +msgstr "Поиск по имени токена" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:207, src/app/main/ui/workspace/tokens/sets/lists.cljs:316 +msgid "workspace.tokens.select-set" +msgstr "Выберите набор." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 +msgid "workspace.tokens.set-edit-placeholder" +msgstr "Введите имя (группы — через «/»)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:374 +msgid "workspace.tokens.set-selection-theme" +msgstr "Укажите, какие наборы токенов входят в эту тему:" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:127 +msgid "workspace.tokens.sets-hint" +msgstr "Правка темы и наборов" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:90 +msgid "workspace.tokens.setting-description" +msgstr "Здесь задаётся базовый размер шрифта — значение 1rem:" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:83 +msgid "workspace.tokens.settings" +msgstr "Настройки токенов" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:236 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Добавить тень" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:163, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:164, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:168 +msgid "workspace.tokens.shadow-blur" +msgstr "Размытие" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:116 +msgid "workspace.tokens.shadow-inset" +msgstr "Внутренняя" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:125 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Убрать тень" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:176, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:181 +msgid "workspace.tokens.shadow-spread" +msgstr "Распространение" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287, ../common/src/app/common/files/tokens.cljc:112 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "Размытие не может быть отрицательным" + +#: ../common/src/app/common/files/tokens.cljc:119 +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "Распространение не может быть отрицательным" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:143 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:154 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:270 +msgid "workspace.tokens.size" +msgstr "Размер" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:75 +msgid "workspace.tokens.source" +msgstr "Источник:" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:311, src/app/main/ui/workspace/tokens/management/context_menu.cljs:335 +msgid "workspace.tokens.stroke-width" +msgstr "Толщина обводки" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:54, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "none | uppercase | lowercase | capitalize или {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:57, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "none | underline | strike-through или {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:85 +msgid "workspace.tokens.themes-description" +msgstr "" +"Здесь управляйте темами: включайте и отключайте их и задавайте активные " +"наборы." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:49, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:83 +msgid "workspace.tokens.themes-list" +msgstr "Список тем" + +#: src/app/main/ui/workspace/tokens/tokens_source.cljs:80 +msgid "workspace.tokens.this-file" +msgstr "Этот файл" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:334, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:335 +msgid "workspace.tokens.token-description" +msgstr "Описание" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:133, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:258 +msgid "workspace.tokens.token-font-family-select" +msgstr "Выберите семейство шрифтов" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "Семейство шрифтов" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "Семейство шрифтов или шрифты через запятую" + +#: src/app/main/ui/ds/controls/shared/token_option.cljs:43, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:88, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:142, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:162, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:298, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:63, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:64, src/app/main/ui/workspace/tokens/management/token_pill.cljs:131 +msgid "workspace.tokens.token-name" +msgstr "Имя" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:273, ../common/src/app/common/files/tokens.cljc:156, ../common/src/app/common/files/tokens.cljc:169 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "По пути %s токен уже есть (или в его начале)." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:270, ../common/src/app/common/files/tokens.cljc:153, ../common/src/app/common/files/tokens.cljc:166, ../common/src/app/common/files/tokens.cljc:289, ../common/src/app/common/files/tokens.cljc:317, ../common/src/app/common/files/tokens.cljc:330 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Имя — минимум 1 символ" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:272, ../common/src/app/common/files/tokens.cljc:155, ../common/src/app/common/files/tokens.cljc:168 +msgid "workspace.tokens.token-name-validation-error" +msgstr "" +" — неверное имя токена.\n" +"Имена токенов — только буквы и цифры через «.» и не с «$» в начале." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:325 +msgid "workspace.tokens.token-value" +msgstr "Значение" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:324, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:131 +msgid "workspace.tokens.token-value-enter" +msgstr "Введите значение или алиас через {alias}" + +#: src/app/main/ui/workspace/tokens/management.cljs:70 +msgid "workspace.tokens.tokens-section-title" +msgstr "ТОКЕНЫ - %s" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:133 +msgid "workspace.tokens.tools" +msgstr "Инструменты" + +#: src/app/main/data/workspace/tokens/import_export.cljs:57 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "" +"Импорт удался, но часть токенов пропущена: неподдерживаемые значения $type. " +"Раскройте подробности — там список." + +#: src/app/main/data/workspace/tokens/import_export.cljs:60 +msgid "workspace.tokens.unknown-token-type-section" +msgstr "Тип «%s» не поддерживается (%s):" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:249, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:200 +msgid "workspace.tokens.use-reference" +msgstr "Взять ссылку" + #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:148 msgid "workspace.tokens.value-not-valid" msgstr "Значение недействительное" +#: src/app/main/ui/workspace/top_toolbar.cljs:70 +msgid "workspace.toolbar.arrow" +msgstr "Стрелка (%s)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Ресурсы" @@ -5799,16 +9148,40 @@ msgstr "Эллипс (%s)" #: src/app/main/ui/workspace/top_toolbar.cljs:66 msgid "workspace.toolbar.frame" -msgstr "Кадр (%s)" +msgstr "Доска (%s)" #: src/app/main/ui/workspace/top_toolbar.cljs:73 msgid "workspace.toolbar.image" msgstr "Изображение (%s)" +#: src/app/main/ui/workspace/top_toolbar.cljs:413 +msgid "workspace.toolbar.label" +msgstr "Рисование" + +#: src/app/main/ui/workspace/top_toolbar.cljs:69 +msgid "workspace.toolbar.line" +msgstr "Линия (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:333 +msgid "workspace.toolbar.mcp" +msgstr "MCP" + +#: src/app/main/ui/workspace/top_toolbar.cljs:345 +msgid "workspace.toolbar.mcp-connect-here" +msgstr "Подключить здесь" + +#: src/app/main/ui/workspace/top_toolbar.cljs:342 +msgid "workspace.toolbar.mcp-connected" +msgstr "MCP подключён" + #: src/app/main/ui/workspace/top_toolbar.cljs:65, src/app/main/ui/workspace/top_toolbar.cljs:425 msgid "workspace.toolbar.move" msgstr "Перемещение (%s)" +#: src/app/main/ui/workspace/palette.cljs:169 +msgid "workspace.toolbar.palette-bar" +msgstr "Палитра" + #: src/app/main/ui/workspace/top_toolbar.cljs:72 msgid "workspace.toolbar.path" msgstr "Контур (%s)" @@ -5842,10 +9215,22 @@ msgstr "Готово" msgid "workspace.top-bar.view-only" msgstr "**Проверка кода** (Только просмотр)" +#: src/app/main/ui/workspace/viewport/top_bar.cljs:39 +msgid "workspace.top-bar.webgl-context-lost" +msgstr "Рендеринг недоступен. Обновите страницу." + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:43 +msgid "workspace.top-bar.webgl-context-lost.reload" +msgstr "Обновить" + #: src/app/main/ui/workspace/sidebar/history.cljs:384 msgid "workspace.undo.empty" msgstr "Изменений в истории ещё нет" +#: src/app/main/ui/workspace/sidebar/history.cljs:364 +msgid "workspace.undo.entry.by" +msgstr "от %s" + #: src/app/main/ui/workspace/sidebar/history.cljs:154 msgid "workspace.undo.entry.delete" msgstr "Удалено %s" @@ -5876,7 +9261,7 @@ msgstr "кривые" #: src/app/main/ui/workspace/sidebar/history.cljs:119 msgid "workspace.undo.entry.multiple.frame" -msgstr "кадры" +msgstr "доски" #: src/app/main/ui/workspace/sidebar/history.cljs:120 msgid "workspace.undo.entry.multiple.group" @@ -5936,7 +9321,7 @@ msgstr "кривая" #: src/app/main/ui/workspace/sidebar/history.cljs:136 msgid "workspace.undo.entry.single.frame" -msgstr "кадр" +msgstr "доска" #: src/app/main/ui/workspace/sidebar/history.cljs:137 msgid "workspace.undo.entry.single.group" @@ -5998,10 +9383,22 @@ msgstr "Обнаружены обновления общих библиотек" msgid "workspace.updates.update" msgstr "Обновить" +#: src/app/main/ui/ds/product/milestone_group.cljs:78 +msgid "workspace.versions.autosaved.entry" +msgstr "Автосохранений: %s" + +#: src/app/main/data/workspace/versions.cljs:301, src/app/main/ui/workspace/sidebar/versions.cljs:300 +msgid "workspace.versions.autosaved.version" +msgstr "Автосохранение %s" + #: src/app/main/ui/workspace/sidebar/versions.cljs:323 msgid "workspace.versions.button.pin" msgstr "Закрепить версию" +#: src/app/main/ui/workspace/sidebar/versions.cljs:233, src/app/main/ui/workspace/sidebar/versions.cljs:313 +msgid "workspace.versions.button.preview" +msgstr "Посмотреть версию" + #: src/app/main/ui/workspace/sidebar/versions.cljs:318 msgid "workspace.versions.button.restore" msgstr "Восстановить версию" @@ -6014,6 +9411,10 @@ msgstr "Сохранить версию" msgid "workspace.versions.empty" msgstr "Версий пока нет" +#: src/app/main/ui/ds/product/milestone_group.cljs:72 +msgid "workspace.versions.expand-snapshot" +msgstr "Развернуть снимки" + #: src/app/main/ui/workspace/sidebar/versions.cljs:398 msgid "workspace.versions.filter.all" msgstr "Все версии" @@ -6026,10 +9427,22 @@ msgstr "Фильтр версий" msgid "workspace.versions.filter.mine" msgstr "Мои версии" +#: src/app/main/ui/workspace/sidebar/versions.cljs:403 +msgid "workspace.versions.filter.user" +msgstr "Версии: %s" + #: src/app/main/ui/workspace/sidebar/versions.cljs:420 msgid "workspace.versions.loading" msgstr "Загрузка..." +#: src/app/main/data/workspace/versions.cljs:312 +msgid "workspace.versions.preview-banner-title" +msgstr "Просмотр версии: %s" + +#: src/app/main/data/workspace/versions.cljs:306 +msgid "workspace.versions.preview.unnamed" +msgstr "Без названия" + #: src/app/main/data/workspace/versions.cljs:239 msgid "workspace.versions.restore-warning" msgstr "Вы хотите восстановить эту версию?" @@ -6042,6 +9455,10 @@ msgstr "Действия" msgid "workspace.versions.tab.history" msgstr "История" +#: src/app/main/ui/ds/product/milestone.cljs:84, src/app/main/ui/ds/product/milestone_group.cljs:91 +msgid "workspace.versions.version-menu" +msgstr "Открыть версии" + #: src/app/main/ui/workspace/sidebar/versions.cljs:65 #, markdown msgid "workspace.versions.warning.subtext" From 1ebb4f3866e75996b20e8db7f65aa8da068676f8 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 14 Oct 2026 10:15:53 +0200 Subject: [PATCH 087/325] :wrench: Fail CI when render-wasm JS glue exports invoke_*V (#12243) * :wrench: Fail CI when render-wasm JS glue exports invoke_*V Add check-no-invoke-v (path from _build_env) and run it in CI: WASM after a production frontend build so JS longjmp + SIMD ABI regressions are caught in the pipeline. * :bug: Fail closed in render-wasm invoke_* glue check * :wrench: Check exporter wasm glue for any invoke_* wrapper --------- Co-authored-by: Elena Torro --- .github/workflows/tests-exporter.yml | 5 ++++ .github/workflows/tests-wasm.yml | 10 +++++++ render-wasm/check-no-invoke-v | 41 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100755 render-wasm/check-no-invoke-v diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index 82b7b12f73..ed0da17b28 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -61,3 +61,8 @@ jobs: working-directory: ./exporter run: | ./scripts/test + + - name: Check no invoke_* in exporter JS glue + working-directory: ./render-wasm + run: | + ./check-no-invoke-v export diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index f3e1547321..eac2d7ebf3 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -60,3 +60,13 @@ jobs: working-directory: ./render-wasm run: | ./test + + - name: Build + working-directory: ./render-wasm + run: | + NODE_ENV=production ./build frontend + + - name: Check no invoke_*V in JS glue + working-directory: ./render-wasm + run: | + ./check-no-invoke-v diff --git a/render-wasm/check-no-invoke-v b/render-wasm/check-no-invoke-v new file mode 100755 index 0000000000..b90502a235 --- /dev/null +++ b/render-wasm/check-no-invoke-v @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Usage: ./check-no-invoke-v [frontend|export] [glue-path] +# +# Fail if the Emscripten JS glue has any invoke_* wrapper. With Wasm EH and +# Wasm SjLj there should be none; invoke_*V (v128) throws a TypeError at the +# JS boundary. +set -euo pipefail + +_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +TARGET=${1:-frontend} +# _build_env reads NODE_ENV without a default; keep set -u happy. +NODE_ENV=${NODE_ENV:-} +RENDER_TARGET=$TARGET +# shellcheck source=/dev/null +. "$_SCRIPT_DIR/_build_env" "$TARGET" + +if [[ $# -ge 2 ]]; then + GLUE=$2 +elif [[ "$TARGET" = "export" ]]; then + GLUE="$_SCRIPT_DIR/$EXPORT_DEST/$BUILD_NAME.js" +else + GLUE="$_SCRIPT_DIR/$FRONTEND_DEST/$BUILD_NAME.js" +fi + +if [[ ! -f "$GLUE" ]]; then + echo "error: JS glue not found: $GLUE" >&2 + echo "hint: run ./build $TARGET first" >&2 + exit 1 +fi + +status=0 +grep -qE 'invoke_[A-Za-z0-9]+' "$GLUE" || status=$? + +if [[ $status -eq 0 ]]; then + echo "error: JS glue has invoke_* wrappers: $GLUE" >&2 + grep -oE 'invoke_[A-Za-z0-9]+' "$GLUE" | sort -u >&2 + exit 1 +elif [[ $status -gt 1 ]]; then + echo "error: grep failed on $GLUE" >&2 + exit "$status" +fi From ba81f2f8bb518ff06c39562460386c3e59022f82 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 14 Oct 2026 14:21:18 +0200 Subject: [PATCH 088/325] :bug: Fix audit-log-archive cron submit and nitrate wiring (#12321) After the unified jobs migration, cron submitted empty params that failed validation because the job schema still required Integrant system keys. Narrow the schema to optional REPL overrides, wire :app.nitrate/client onto the archive job-def, and drop the unused ::jobs/defs ref from nitrate that created an Integrant cycle. AI-assisted-by: composer --- backend/src/app/loggers/audit/archive_task.clj | 8 +++----- backend/src/app/main.clj | 10 ++++++---- .../backend_tests/loggers_audit_archive_task_test.clj | 11 +++++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 1264882eaa..6c29c29d6b 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -256,12 +256,10 @@ (declare execute-audit-log-archive) (def schema:audit-log-archive-params - "Optional overrides for the repl invocation defaults." + "Optional overrides for the repl invocation defaults. + Cron submits {}; system deps (pool, shared-keys, http) live on the + job-def cfg closed over by the handler, not in job params." [:map - ::db/pool - ::setup/shared-keys - ::http/client - [:app.nitrate/client {:optional true} [:maybe :map]] [:enabled {:optional true} :boolean] [:uri {:optional true} ::sm/uri]]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d5aa7538c0..2c34fc8c94 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -543,7 +543,8 @@ :app.nitrate/client {::http.client/client (ig/ref ::http.client/client) - ::jobs/defs (ig/ref ::jobs/defs) + ;; No ::jobs/defs: nitrate never submits jobs, and archive-task's + ;; job-def needs this client — that would cycle through jobs/defs. ::setup/shared-keys (ig/ref ::setup/shared-keys)} :app.rpc/management-methods @@ -679,10 +680,11 @@ ::mtx/metrics (ig/ref ::mtx/metrics)} :app.loggers.audit.archive-task/job-def - {::db/pool (ig/ref ::db/pool) + {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) - ::setup/shared-keys (ig/ref ::setup/shared-keys) - ::mtx/metrics (ig/ref ::mtx/metrics)} + ::setup/shared-keys (ig/ref ::setup/shared-keys) + :app.nitrate/client (ig/ref :app.nitrate/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} :app.loggers.audit.gc-task/job-def {::db/pool (ig/ref ::db/pool) diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index 863885a085..a04c2c2fd1 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -7,12 +7,15 @@ (ns backend-tests.loggers-audit-archive-task-test (:require [app.common.json :as json] + [app.common.schema :as sm] [app.common.time :as ct] + [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http.client :as-alias http] [app.jobs :as jobs] + [app.loggers.audit.archive-task :as archive] [app.setup :as-alias setup] [backend-tests.helpers :as th] [clojure.test :as t] @@ -348,3 +351,11 @@ (t/is (= (:name team) (:team-name props)))) (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] (t/is (= 2 (count rows)))))))) + +(t/deftest archive-params-accept-empty-cron-props + ;; Cron always submits (or props {}) — system deps live on the + ;; job-def cfg, not in params. Requiring them here blocks every tick. + (let [valid? (sm/validator archive/schema:audit-log-archive-params)] + (t/is (true? (valid? {}))) + (t/is (true? (valid? {:enabled true}))) + (t/is (true? (valid? {:uri (u/uri archive-uri)}))))) From 286a6d5c9a75821681b6e9697521a09468692678 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 14 Oct 2026 15:24:53 +0200 Subject: [PATCH 089/325] :sparkles: Add new dashboard methods to the API (#12169) * :sparkles: Add new dashboard methods to the API * :bug: Fix some issues with the new methods * :bug: Fix problem with mcp test --- common/src/app/common/types/plugins.cljc | 7 +- docs/plugins/getting-started.md | 6 + .../scripts/check-translations/words.es.txt | 1 + frontend/src/app/main/data/dashboard.cljs | 8 + frontend/src/app/main/data/workspace/mcp.cljs | 6 +- .../src/app/main/ui/workspace/plugins.cljs | 21 + frontend/src/app/plugins/api.cljs | 2 +- frontend/src/app/plugins/library.cljs | 30 +- frontend/src/app/plugins/management.cljs | 599 +++++++++++++--- frontend/src/app/plugins/register.cljs | 14 +- .../frontend_tests/data/dashboard_test.cljs | 18 + .../frontend_tests/plugins/library_test.cljs | 35 +- .../plugins/management_test.cljs | 674 +++++++++++++----- .../frontend_tests/plugins/register_test.cljs | 6 + frontend/translations/en.po | 12 + frontend/translations/es.po | 12 + .../plugin/src/ExecuteCodeTaskConsole.test.ts | 2 +- .../plugin/src/ExecuteCodeTaskConsole.ts | 171 +++++ .../task-handlers/ExecuteCodeTaskHandler.ts | 173 +---- mcp/packages/server/data/api_types.yml | 448 ++++++++++-- .../server/data/initial_instructions.md | 16 +- plugins/CHANGELOG.md | 4 +- plugins/apps/plugin-api-test-suite/README.md | 69 +- .../apps/plugin-api-test-suite/ci/run-ci.ts | 135 +++- .../public/manifest.json | 7 +- .../src/framework/runner.ts | 83 ++- .../src/framework/temporary.ts | 14 + .../src/framework/types.ts | 17 +- .../src/generated/api-surface.json | 319 ++++++++- .../src/tests/management.test.ts | 555 ++++++++++++++ .../src/tests/pages.test.ts | 5 +- .../plugin-api-test-suite/src/tests/wait.ts | 17 + plugins/libs/plugin-types/index.d.ts | 256 +++++-- .../libs/plugins-runtime/src/lib/api/index.ts | 24 +- .../src/lib/api/plugin-api.spec.ts | 166 ++--- .../src/lib/models/manifest.schema.ts | 3 + 36 files changed, 3157 insertions(+), 778 deletions(-) create mode 100644 mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/framework/temporary.ts create mode 100644 plugins/apps/plugin-api-test-suite/src/tests/management.test.ts diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 8db72497fd..3b32ccbaed 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -36,11 +36,14 @@ "user:read" "allow:downloads" "allow:localstorage" - "allow:global"}) + "allow:global" + "manage:projects" + "manage:teams" + "manage:delete"}) (def schema:permissions "Schema for plugin permissions - a set of valid permission strings." - [:set {:gen/max 12} (into [:enum] (sort valid-permissions))]) + [:set {:gen/max 15} (into [:enum] (sort valid-permissions))]) (def max-plugins "Maximum number of plugins a profile can hold." diff --git a/docs/plugins/getting-started.md b/docs/plugins/getting-started.md index 5f72ac4ada..e74cbce541 100644 --- a/docs/plugins/getting-started.md +++ b/docs/plugins/getting-started.md @@ -189,6 +189,12 @@ Typical use cases: storing authentication tokens for a plugin login - allow:global: Allows the plugin to keep running while the user moves between the dashboard and files, and to find and open their projects and files. Added automatically when the manifest sets "scope": "global". Typical use cases: assistants or integrations that work across several files. +- manage:projects: Allows a global plugin to create, rename, pin and duplicate projects. + +- manage:teams: Allows a global plugin to list, create and rename the user's teams, and move projects between them. + +- manage:delete: Allows a global plugin to delete files, projects and teams. + _Note: Write permissions automatically includes its corresponding read permission (e.g., content:write includes content:read) because reading is required to perform write or modification actions._ ### What are plugin.ts and plugin.js files? diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index 009f4c1c4e..89cfcb7dc0 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -262,6 +262,7 @@ eliminarla eliminarlo eliminarlos ella +ellos eso esos este diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index b0e47eafea..28f36671ff 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -282,6 +282,13 @@ ;; --- EVENT: duplicate-project +(defn project-with-file-count + "Loads the file count missing from a duplicated project's RPC response." + [{:keys [id] :as project}] + (->> (rp/cmd! :get-project-files {:project-id id}) + (rx/map (fn [files] + (assoc project :count (count files) :is-pinned false))))) + (defn project-duplicated [{:keys [id] :as project}] (ptk/reify ::project-duplicated @@ -312,6 +319,7 @@ (str " " copy-count)))) new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)] (->> (rp/cmd! :duplicate-project {:project-id id :name new-name}) + (rx/mapcat project-with-file-count) (rx/tap on-success) (rx/map project-duplicated) (rx/catch on-error)))))) diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index e4b03fb956..58ce2f5060 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -39,11 +39,7 @@ :allow-background true :scope "global" :permissions - #{"user:read" - "library:read" "library:write" - "comment:read" "comment:write" - "content:write" "content:read" - "allow:global"}}) + preg/mcp-permissions}) (defonce interval-sub (atom nil)) diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 12bd45e766..478566b45a 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -338,6 +338,27 @@ [:p {:class (stl/css :permissions-list-text)} (tr "workspace.plugins.permissions.allow-global")]]) + (cond + (contains? permissions "manage:projects") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.manage-projects")]]) + + (cond + (contains? permissions "manage:teams") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.manage-teams")]]) + + (cond + (contains? permissions "manage:delete") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.manage-delete")]]) + (cond (contains? permissions "clipboard:write") [:div {:class (stl/css :permissions-list-entry)} diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 2f0944f800..c2a745cf13 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -90,7 +90,7 @@ ;; Public properties :management - {:get (fn [] (management/create-context))} + {:get (fn [] (management/create-context plugin-id))} :version {:this true diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 56782eb1e2..661e3d9b0d 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1206,6 +1206,25 @@ (let [file (u/locate-file file-id)] (apply array (keys (dm/get-in file [:data :plugin-data (keyword "shared" namespace)])))))))) +(defn- loaded-file-ids + "Ids of the files whose data is loaded: the open file and its libraries. + Files left behind by earlier workspaces stay in `:files` without data." + [state] + (into #{} (keep (fn [[id file]] (when (:data file) id))) (:files state))) + +(defn- on-library-loaded + "Calls `f` once the data of `library-id` is in the workspace state." + [library-id f] + (let [key (js/Symbol) + loaded? #(some? (dm/get-in % [:files library-id :data]))] + (if (loaded? @st/state) + (f) + (add-watch st/state key + (fn [_ _ _ state] + (when (loaded? state) + (remove-watch st/state key) + (f))))))) + (defn library-subcontext [plugin-id] (obj/reify {:name "PenpotLibrarySubcontext"} @@ -1217,15 +1236,14 @@ :connected {:get (fn [] - (let [libraries (get @st/state :files)] - (apply array (->> libraries keys (map (partial library-proxy plugin-id))))))} + (apply array (map (partial library-proxy plugin-id) (loaded-file-ids @st/state))))} :availableLibraries (fn [] (let [team-id (:current-team-id @st/state)] (js/Promise. (fn [resolve reject] - (let [current-libs (into #{} (map first) (get @st/state :files))] + (let [current-libs (loaded-file-ids @st/state)] (->> (rp/cmd! :get-team-shared-files {:team-id team-id}) (rx/map (fn [result] (->> result @@ -1267,7 +1285,11 @@ (->> st/stream (rx/filter (ptk/type? ::dwl/link-file-to-library-finished)) (rx/take 1) - (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) + (rx/subs! (fn [_] + (on-library-loaded + library-id + #(resolve (library-proxy plugin-id library-id)))) + reject)) (st/emit! (-> (dwl/link-file-to-library file-id library-id) (se/add-event plugin-id)))))) reject))))))))) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 0337da56ba..727939b8b7 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -6,34 +6,151 @@ (ns app.plugins.management (:require + [app.common.data :as d] [app.common.features :as cfeat] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.dashboard :as dd] + [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.team :as dtm] + [app.main.features :as features] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] + [app.plugins.register :as r] + [app.plugins.system-events :as se] + [app.plugins.utils :as u] [app.util.object :as obj] + [app.util.sse :as sse] [beicon.v2.core :as rx] [clojure.set :as set] [cuerdas.core :as str] [potok.v2.core :as ptk])) -(defn- project-summary - [project] - #js {:id (str (:id project)) - :teamId (str (:team-id project)) - :name (:name project) - :isDefault (boolean (:is-default project)) - :fileCount (or (:count project) 0)}) +(declare project-proxy) +(declare file-proxy) -(defn- file-summary - [file] - #js {:id (str (:id file)) - :teamId (str (:team-id file)) - :projectId (str (:project-id file)) - :name (:name file) - :modifiedAt (.toISOString ^js (:modified-at file))}) +(defn- dashboard-of-team? + "True when the dashboard of `team-id` is open. Outside the dashboard `:files` + holds the workspace files and libraries, so dashboard updates do not apply." + [state team-id] + (and (str/starts-with? (name (or (rt/lookup-name state) "")) "dashboard") + (= team-id (:current-team-id state)))) + +(defn- update-dashboard + "Applies `update-fn` to the state when the dashboard of `team-id` is open." + [team-id update-fn] + (st/emit! (fn [state] + (if (dashboard-of-team? state team-id) + (update-fn state) + state)))) + +(defn- refresh-restored-project + [team-id project-id] + (if (dashboard-of-team? @st/state team-id) + (->> (rx/zip (rp/cmd! :get-projects {:team-id team-id}) + (rp/cmd! :get-project-files {:project-id project-id}) + (rp/cmd! :get-team-deleted-files {:team-id team-id})) + (rx/tap (fn [[projects files deleted-files]] + (update-dashboard + team-id + (fn [state] + (-> state + (update :projects merge (d/index-by :id projects)) + (update :files merge (d/index-by :id (concat files deleted-files))) + (assoc :deleted-files (d/index-by :id deleted-files))))))) + (rx/map (constantly nil))) + (rx/of nil))) + +(defn- restore-deleted-files + "Uses the dashboard's Trash operation and waits for its final result." + [team-id ids] + (->> (rp/cmd! ::sse/restore-deleted-team-files {:team-id team-id :ids ids}) + (rx/filter sse/end-of-stream?) + (rx/take 1) + (rx/map sse/get-payload) + (rx/reduce (fn [_ restored] restored) nil) + (rx/mapcat (fn [restored] + (cond + (nil? restored) + (rx/throw (js/Error. "Restoration ended without a result")) + + (not (set/subset? ids restored)) + (rx/throw (js/Error. "Some files could not be restored; they may no longer exist")) + + :else + (rx/of nil)))))) + +(defn- valid-name + "Returns `value` trimmed when it is a name of 1 to 250 characters." + [value] + (when (string? value) + (let [value (str/trim value)] + (when (<= 1 (count value) 250) + value)))) + +(defn- permission-error + [permission] + (js/Error. (str "Permission " permission " is not granted"))) + +(defn- backend-error + "Adds the backend type and code to plugin errors without exposing RPC data." + [error] + (if-let [{:keys [type code hint]} (ex-data error)] + (let [details (str/join "/" (keep #(some-> % name) [type code])) + message (or hint (ex-message error)) + result (js/Error. (str message (when (seq details) (str " (" details ")"))))] + (obj/set! result "type" (some-> type name)) + (obj/set! result "code" (some-> code name)) + result) + error)) + +(defn- request + "Resolves with the single value of the stream built by `make-stream` when + the plugin has `permission`." + [plugin-id permission make-stream] + (if (r/check-permission plugin-id permission) + (js/Promise. + (fn [resolve reject] + (rx/subs! resolve #(reject (backend-error %)) (make-stream)))) + (js/Promise.reject (permission-error permission)))) + +(defn- set-property + "Runs a setter's RPC before updating the proxy and app state. Async failures + are logged even when synchronous validation errors are configured to throw." + [plugin-id data attr value event] + (st/emit! + (se/add-event + (ptk/reify ::set-property + ev/Event + (-data [_] + (assoc (if (satisfies? ev/Event event) (ev/-data event) {}) + ::ev/name (name (ptk/type event)))) + + ptk/WatchEvent + (watch [_ state stream] + (->> (rx/concat + (ptk/watch event state stream) + (rx/of (fn [state] + (swap! data assoc attr value) + (ptk/update event state)))) + (rx/catch (fn [error] + (u/display-not-valid attr (.-message (backend-error error))) + (rx/empty)))))) + plugin-id))) + +(defn- set-project-pin + [{:keys [id is-pinned] :as params}] + (ptk/reify ::set-project-pin + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:projects id] assoc :is-pinned is-pinned)) + + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :update-project-pin params) + (rx/ignore))))) (defn workspace-context [state] @@ -93,98 +210,390 @@ (finish (js/Error. "File navigation was superseded")))))) (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) -(defn list-projects - [options] - (let [team-id (if-let [id (obj/get options "teamId")] - (uuid/parse* id) - (:current-team-id @st/state))] - (if (nil? team-id) - (js/Promise.reject (js/Error. "Expected a team UUID")) - (js/Promise. - (fn [resolve reject] - (->> (rp/cmd! :get-projects {:team-id team-id}) - (rx/map (fn [projects] - (into-array (map project-summary (remove :deleted-at projects))))) - (rx/subs! resolve reject))))))) +(defn- list-projects + [plugin-id team-id] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (->> projects + (remove :deleted-at) + (map (partial project-proxy plugin-id)) + (into-array))))))) -(defn list-files - [options] - (let [project-id (uuid/parse* (obj/get options "projectId"))] - (if (nil? project-id) - (js/Promise.reject (js/Error. "Expected a project UUID")) - (js/Promise. - (fn [resolve reject] - (->> (rp/cmd! :get-project-files {:project-id project-id}) - (rx/map (fn [files] - (into-array (map file-summary files)))) - (rx/subs! resolve reject))))))) +(defn- list-deleted-projects + [plugin-id team-id] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (into-array (map (partial project-proxy plugin-id) + (filter (fn [project] + (some-> (:deleted-at project) (ct/is-after? (ct/now)))) + projects)))))))) -(defn- creation-name - [options] - (let [name (obj/get options "name")] - (when (string? name) - (let [name (str/trim name)] - (when (<= 1 (count name) 250) - name))))) +(defn- list-deleted-files + [plugin-id team-id project-id] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-team-deleted-files {:team-id team-id}) + (rx/map (fn [files] + (into-array (map (partial file-proxy plugin-id) + (cond->> (filter (fn [file] + (some-> (:will-be-deleted-at file) (ct/is-after? (ct/now)))) + files) + project-id (filter (fn [file] (= project-id (:project-id file)))))))))))) -(defn create-project - [options] - (let [team-id (if-let [id (obj/get options "teamId")] - (uuid/parse* id) - (:current-team-id @st/state)) - name (creation-name options)] - (cond - (nil? team-id) - (js/Promise.reject (js/Error. "Expected a team UUID")) +(defn- create-project + [plugin-id team-id options] + (if-let [name (valid-name (obj/get options "name"))] + (request plugin-id "manage:projects" + #(->> (rp/cmd! :create-project {:team-id team-id :name name}) + (rx/map (fn [project] (assoc project :count 0))) + (rx/tap (fn [project] + (update-dashboard team-id + (fn [state] + (assoc-in state [:projects (:id project)] project))))) + (rx/map (partial project-proxy plugin-id)))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) - (nil? name) - (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) +(defn- file-metadata + "Fetches the metadata of a file and its team. A file loaded in the workspace + is read from the state instead of downloading it again." + [file-id] + (let [file (dsh/lookup-file @st/state file-id)] + (->> (if (:project-id file) + (rx/of file) + (rp/cmd! :get-file {:id file-id :features (:features @st/state)})) + (rx/mapcat (fn [file] + (->> (rp/cmd! :get-project {:id (:project-id file)}) + (rx/map (fn [project] + (-> (dissoc file :data) + (assoc :team-id (:team-id project))))))))))) - :else - (js/Promise. - (fn [resolve reject] - (->> (rp/cmd! :create-project {:team-id team-id :name name}) - (rx/tap (fn [project] - (st/emit! - (fn [state] - (if (= (:team-id project) (:current-team-id state)) - (assoc-in state [:projects (:id project)] (assoc project :count 0)) - state))))) - (rx/map project-summary) - (rx/subs! resolve reject))))))) +(defn- get-file + [plugin-id file-id] + (if-let [file-id (uuid/parse* file-id)] + (request plugin-id "content:read" + #(->> (file-metadata file-id) + (rx/map (partial file-proxy plugin-id)))) + (js/Promise.reject (js/Error. "Expected a file UUID")))) -(defn create-file - [options] - (let [project-id (uuid/parse* (obj/get options "projectId")) - name (creation-name options)] - (cond - (nil? project-id) - (js/Promise.reject (js/Error. "Expected a project UUID")) +(defn team-proxy + [plugin-id team] + (let [data (atom team)] + (obj/reify {:name "TeamProxy"} + :$plugin {:enumerable false :get (fn [] plugin-id)} - (nil? name) - (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + :id + {:get #(str (:id @data))} - :else - (js/Promise. - (fn [resolve reject] - (let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] - (->> (rp/cmd! :create-file {:project-id project-id :name name :features features}) - (rx/tap (fn [file] - (st/emit! - (fn [state] - (if (= (:team-id file) (:current-team-id state)) - (ptk/update (dd/file-created file) state) - state))))) - (rx/map file-summary) - (rx/subs! resolve reject)))))))) + :isDefault + {:get #(boolean (:is-default @data))} + + :name + {:get #(:name @data) + :set + (fn [value] + (let [name (valid-name value)] + (cond + (not (r/check-permission plugin-id "manage:teams")) + (u/not-valid plugin-id :name "Plugin doesn't have 'manage:teams' permission") + + (nil? name) + (u/not-valid plugin-id :name value) + + :else + (set-property plugin-id data :name name + (dtm/update-team {:id (:id @data) :name name})))))} + + :listProjects + (fn [] (list-projects plugin-id (:id @data))) + + :listDeletedProjects + (fn [] (list-deleted-projects plugin-id (:id @data))) + + :listDeletedFiles + (fn [] (list-deleted-files plugin-id (:id @data) nil)) + + :createProject + (fn [options] (create-project plugin-id (:id @data) options))))) + +(defn project-proxy + [plugin-id project] + (let [data (atom project)] + (obj/reify {:name "ProjectProxy"} + :$plugin {:enumerable false :get (fn [] plugin-id)} + + :id + {:get #(str (:id @data))} + + :teamId + {:get #(str (:team-id @data))} + + :isDefault + {:get #(boolean (:is-default @data))} + + :fileCount + {:get #(or (:count @data) 0)} + + :name + {:get #(:name @data) + :set + (fn [value] + (let [name (valid-name value)] + (cond + (not (r/check-permission plugin-id "manage:projects")) + (u/not-valid plugin-id :name "Plugin doesn't have 'manage:projects' permission") + + (nil? name) + (u/not-valid plugin-id :name value) + + :else + (set-property plugin-id data :name name + (dd/rename-project {:id (:id @data) :name name})))))} + + :pinned + {:get #(boolean (:is-pinned @data)) + :set + (fn [value] + (cond + (not (r/check-permission plugin-id "manage:projects")) + (u/not-valid plugin-id :pinned "Plugin doesn't have 'manage:projects' permission") + + (not (boolean? value)) + (u/not-valid plugin-id :pinned value) + + :else + (set-property plugin-id data :is-pinned value + (set-project-pin {:id (:id @data) + :team-id (:team-id @data) + :is-pinned value}))))} + + :listFiles + (fn [] + (request plugin-id "content:read" + #(->> (rp/cmd! :get-project-files {:project-id (:id @data)}) + (rx/map (fn [files] + (into-array (map (fn [file] + (file-proxy plugin-id (assoc file :team-id (:team-id @data)))) + files))))))) + + :listDeletedFiles + (fn [] (list-deleted-files plugin-id (:team-id @data) (:id @data))) + + :restore + (fn [] + (request plugin-id "manage:projects" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :get-team-deleted-files {:team-id team-id}) + (rx/mapcat (fn [files] + (let [ids (into #{} (comp (filter (fn [file] (= id (:project-id file)))) + (map :id)) files)] + (if (seq ids) + (restore-deleted-files team-id ids) + (rx/throw (js/Error. "Cannot restore a project without recoverable files")))))) + (rx/tap (fn [_] (swap! data dissoc :deleted-at))) + (rx/mapcat (fn [_] (refresh-restored-project team-id id))))))) + + :createFile + (fn [options] + (if-let [name (valid-name (obj/get options "name"))] + (request plugin-id "content:write" + #(let [{:keys [id team-id]} @data + features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] + (->> (rp/cmd! :create-file {:project-id id :name name :features features}) + (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) + (rx/tap (fn [file] + (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) + (rx/map (partial file-proxy plugin-id))))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) + + :duplicate + (fn [options] + (let [name (obj/get options "name")] + (if (and (some? name) (nil? (valid-name name))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + (request plugin-id "manage:projects" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :duplicate-project (cond-> {:project-id id} + (some? name) (assoc :name (valid-name name)))) + (rx/mapcat dd/project-with-file-count) + (rx/tap (fn [project] + (update-dashboard team-id (fn [state] (ptk/update (dd/project-duplicated project) state))))) + (rx/map (partial project-proxy plugin-id)))))))) + + :moveTo + (fn [team] + (if-let [team-id (uuid/parse* (obj/get team "id"))] + (request plugin-id "manage:teams" + #(let [{:keys [id] :as project} @data] + (->> (rp/cmd! :move-project {:project-id id :team-id team-id}) + (rx/tap (fn [_] + (swap! data assoc :team-id team-id) + (update-dashboard (:team-id project) (fn [state] (update state :projects dissoc id))))) + (rx/map (constantly nil))))) + (js/Promise.reject (js/Error. "Expected a team")))) + + :remove + (fn [] + (request plugin-id "manage:delete" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :delete-project {:id id}) + (rx/tap (fn [_] + (update-dashboard team-id (fn [state] (update state :projects dissoc id))))) + (rx/map (constantly nil))))))))) + +(defn file-proxy + [plugin-id file] + (let [data (atom file)] + (obj/reify {:name "ProjectFileProxy"} + :$plugin {:enumerable false :get (fn [] plugin-id)} + + :id + {:get #(str (:id @data))} + + :teamId + {:get #(str (:team-id @data))} + + :projectId + {:get #(str (:project-id @data))} + + :modifiedAt + {:get #(.toISOString ^js (:modified-at @data))} + + :name + {:get #(:name @data) + :set + (fn [value] + (let [name (valid-name value)] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + (nil? name) + (u/not-valid plugin-id :name value) + + :else + (set-property plugin-id data :name name + (dd/rename-file {:id (:id @data) :name name})))))} + + :shared + {:get #(boolean (:is-shared @data)) + :set + (fn [value] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :shared "Plugin doesn't have 'library:write' permission") + + (not (boolean? value)) + (u/not-valid plugin-id :shared value) + + :else + (set-property plugin-id data :is-shared value + (dd/set-file-shared {:id (:id @data) :is-shared value}))))} + + :open + (fn [] + (if (r/check-permission plugin-id "content:read") + (open-file (str (:id @data)) #js {:teamId (str (:team-id @data))}) + (js/Promise.reject (permission-error "content:read")))) + + :restore + (fn [] + (request plugin-id "content:write" + #(->> (restore-deleted-files (:team-id @data) #{(:id @data)}) + (rx/tap (fn [_] + (swap! data dissoc :deleted-at :will-be-deleted-at))) + (rx/mapcat (fn [_] (refresh-restored-project (:team-id @data) (:project-id @data))))))) + + :moveTo + (fn [project] + (if-let [project-id (uuid/parse* (obj/get project "id"))] + (request plugin-id "content:write" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :get-project {:id project-id}) + (rx/mapcat (fn [target] + (if (= team-id (:team-id target)) + (rp/cmd! :move-files {:ids #{id} :project-id project-id}) + (rx/throw (js/Error. "Cannot move a file to a project in another team"))))) + (rx/tap (fn [_] + (update-dashboard team-id (fn [state] (ptk/update (dd/move-files {:ids #{id} :project-id project-id}) state))) + (swap! data assoc :project-id project-id))) + (rx/map (constantly nil))))) + (js/Promise.reject (js/Error. "Expected a project")))) + + :duplicate + (fn [options] + (let [name (obj/get options "name")] + (if (and (some? name) (nil? (valid-name name))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + (request plugin-id "content:write" + #(let [{:keys [id team-id]} @data] + (->> (rp/cmd! :duplicate-file (cond-> {:file-id id} + (some? name) (assoc :name (valid-name name)))) + (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) + (rx/tap (fn [file] + (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) + (rx/map (partial file-proxy plugin-id)))))))) + + :remove + (fn [] + (request plugin-id "manage:delete" + #(let [{:keys [id project-id team-id]} @data] + (->> (rp/cmd! :delete-file {:id id}) + (rx/tap (fn [_] + (update-dashboard team-id + (fn [state] + (->> state + (ptk/update (dd/delete-file {:id id :project-id project-id})) + (ptk/update (dd/file-deleted project-id))))))) + (rx/map (constantly nil))))))))) (defn create-context - [] + [plugin-id] (obj/reify {:name "PenpotManagementContext"} :workspace {:get (fn [] (clj->js (workspace-context @st/state)))} + :openFile open-file - :listProjects list-projects - :listFiles list-files - :createProject create-project - :createFile create-file)) + + :getFile + (fn [file-id] (get-file plugin-id file-id)) + + :listTeams + (fn [] + (request plugin-id "manage:teams" + #(->> (rp/cmd! :get-teams) + (rx/map (fn [teams] + (into-array (map (partial team-proxy plugin-id) teams))))))) + + :createTeam + (fn [options] + (if-let [name (valid-name (obj/get options "name"))] + (request plugin-id "manage:teams" + #(->> (rp/cmd! :create-team {:name name :features features/global-enabled-features}) + (rx/tap (fn [_] (st/emit! (dtm/fetch-teams)))) + (rx/map (partial team-proxy plugin-id)))) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) + + :listProjects + (fn [options] + (if-let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (list-projects plugin-id team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")))) + + :listDeletedProjects + (fn [options] + (if-let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (list-deleted-projects plugin-id team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")))) + + :listDeletedFiles + (fn [options] + (if-let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (list-deleted-files plugin-id team-id nil) + (js/Promise.reject (js/Error. "Expected a team UUID")))))) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index 8b5bcc3bde..0c591eeb28 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -22,6 +22,11 @@ ;; cause a circular dependency (def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae") +(def mcp-permissions + #{"user:read" "library:read" "library:write" + "comment:read" "comment:write" "content:read" "content:write" + "allow:global" "manage:projects" "manage:teams" "manage:delete"}) + ;; Promise that resolves when plugins runtime is initialized. ;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace (defonce ^:private runtime-ready-promise (p/deferred)) @@ -267,10 +272,11 @@ (defn check-permission [plugin-id permission] - (or (= plugin-id "00000000-0000-0000-0000-000000000000") - (= plugin-id mcp-plugin-id) - (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] - (contains? permissions permission)))) + (cond + (= plugin-id "00000000-0000-0000-0000-000000000000") true + (= plugin-id mcp-plugin-id) (contains? mcp-permissions permission) + :else (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] + (contains? permissions permission)))) (defn get-plugin-data [state plugin-id] diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index 12fefdb476..9d5fbd1ce3 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -19,6 +19,24 @@ [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) +(t/deftest ^:async duplicated-project-has-a-file-count-and-is-unpinned + (let [id (uuid/next) + copy-id (uuid/next) + team-id (uuid/next) + copied {:id copy-id :team-id team-id :name "Copy"} + state {:projects {id {:id id :name "Source" :count 9 :is-pinned true}}} + result (atom state)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [cmd _] + (->> (rx/of (case cmd + :duplicate-project copied + :get-project-files [{:id (uuid/next)} {:id (uuid/next)}])) + (rx/observe-on :async))))} + (await (async/observe (ptk/watch (dd/duplicate-project {:id id :name "Source"}) state (rx/empty)) + :on-next #(swap! result (partial ptk/update %)))))) + (t/is (= (assoc copied :count 2 :is-pinned false) (get-in @result [:projects copy-id]))))) + (t/deftest moving-current-team-into-sso-organization-redirects (t/async done (let [team-id (uuid/next) diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 70d3e3fb7d..25122a9104 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -239,19 +239,26 @@ team-id (uuid/next) library-id (uuid/next) stream (rx/subject) - events (atom [])] + events (atom []) + state (atom {:current-file-id file-id :current-team-id team-id})] (await (mock/with-mocks* - {st/state (atom {:current-file-id file-id :current-team-id team-id}) + {st/state state st/stream stream st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) rp/cmd! (shared-files-cmd [{:id library-id :name "Published"}]) r/check-permission (mock/stub (constantly true))} (let [context (library/library-subcontext plugin-id) - result (settled (.connectLibrary context (str library-id))) + promise (.connectLibrary context (str library-id)) + result (settled promise) + done? (atom false) link? #(= ::dwl/link-file-to-library (ptk/type %))] + (.then promise #(reset! done? true)) (await (async/wait-for #(some link? @events) "link event emitted")) (rx/push! stream (ptk/data-event ::dwl/link-file-to-library-finished)) + (await (async/settle)) + (t/is (false? @done?) "waits until the library data is loaded") + (swap! state assoc-in [:files library-id :data] {:id library-id}) (let [{:keys [resolved]} (await result) link-data (ev/-data (first (filter link? @events)))] (t/is (= library-id (obj/get resolved "$id"))) @@ -312,3 +319,25 @@ {:keys [rejected]} (await (settled (.connectLibrary context (str (uuid/next)))))] (t/is (str/includes? (str rejected) "Plugin doesn't have 'library:write' permission")) (t/is (empty? @mock/rpc-calls)))))) + +(t/deftest ^:async library-context-ignores-files-left-by-earlier-workspaces + (let [file-id (uuid/next) + linked-id (uuid/next) + visited-id (uuid/next) + other-id (uuid/next)] + (await + (mock/with-mocks* + {st/state (atom {:current-file-id file-id + :current-team-id (uuid/next) + :files {file-id {:id file-id :data {}} + linked-id {:id linked-id :data {}} + visited-id {:id visited-id}}}) + rp/cmd! (shared-files-cmd [{:id linked-id :name "Linked"} + {:id visited-id :name "Visited"} + {:id other-id :name "Other"}]) + r/check-permission (mock/stub (constantly true))} + (let [context (library/library-subcontext plugin-id)] + (t/is (= #{file-id linked-id} + (into #{} (map #(obj/get % "$id")) (.-connected context)))) + (t/is (= #{(str visited-id) (str other-id)} + (into #{} (map #(.-id %)) (await (.availableLibraries context)))))))))) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index eb74e0b09d..75889e3ec5 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -7,18 +7,29 @@ (ns frontend-tests.plugins.management-test (:require [app.common.uuid :as uuid] + [app.main.data.dashboard :as dd] + [app.main.data.team :as dtm] [app.main.repo :as rp] [app.main.store :as st] [app.plugins.events :as events] [app.plugins.management :as management] + [app.plugins.register :as r] + [app.plugins.utils :as u] + [app.util.object :as obj] + [app.util.sse :as sse] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] - [frontend-tests.helpers.mock :as mock])) + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) (def file-id (uuid/next)) (def page-id (uuid/next)) (def team-id (uuid/next)) +(def project-id (uuid/next)) + +;; The zero plugin id always holds every permission. +(def plugin-id (str uuid/zero)) (def dashboard {:route {:data {:name :dashboard-recent}} @@ -34,9 +45,57 @@ (def ready (assoc loading :current-page-id page-id - :files {file-id {:id file-id :name "Design" + :files {file-id {:id file-id :name "Design" :project-id project-id :data {:pages-index {page-id {:id page-id :objects {}}}}}})) +(def team + {:id team-id :name "Team" :is-default true}) + +(def project + {:id project-id :team-id team-id :name "Drafts" :is-default true :is-pinned false :count 2}) + +(def file + {:id file-id :team-id team-id :project-id project-id :name "Design" + :is-shared false :modified-at (js/Date. "2026-01-02T03:04:05Z")}) + +(t/deftest teams-do-not-expose-deletion + (doseq [id [plugin-id r/mcp-plugin-id]] + (t/is (nil? (obj/get (management/team-proxy id team) "remove"))))) + +(defn- props + "Reads `keys` from a proxy into a map." + [proxy keys] + (into {} (map (fn [k] [k (obj/get proxy k)])) keys)) + +(defn- message + "Resolves with the rejection message of `promise`, or nil when it resolves." + [promise] + (.then promise (fn [] nil) #(.-message %))) + +(defn- cmd-stub + "Mock `rp/cmd!` recording calls in `requests` and answering with `respond`." + [requests respond] + (mock/stub + (fn + ([command] (swap! requests conj [command]) (respond command nil)) + ([command params] (swap! requests conj [command params]) (respond command params))))) + +(defn- emit-stub + "Mock `st/emit!` applying update functions to `state` and recording events." + [state events] + (mock/stub + (fn [event] + (if (fn? event) + (swap! state event) + (do (swap! events conj event) + (when (satisfies? ptk/UpdateEvent event) + (swap! state (partial ptk/update event)))))))) + +(defn- throwing + "Makes validation errors of the test plugin throw." + [state] + (assoc-in state [:plugins :flags plugin-id :throw-validation-errors] true)) + (t/deftest workspace-readiness-requires-an-initialized-page (t/is (= "none" (:status (management/workspace-context dashboard)))) (t/is (= "loading" (:status (management/workspace-context loading)))) @@ -57,7 +116,7 @@ result (-> (management/open-file (str file-id) nil) (.then #(reset! done? true)))] (try - (await (a/wait-for #(= ["loading"] @seen))) + (await (a/wait-for #(= ["loading"] @seen) "workspace loading")) (t/is (false? @done?)) (reset! state ready) (await result) @@ -73,208 +132,435 @@ {st/state state st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0)) ([_ & _] nil))} - (let [result (-> (management/open-file (str file-id) nil) - (.then (fn [] nil) (fn [error] (.-message error))))] - (await (a/wait-for #(= loading @state))) + (let [result (message (management/open-file (str file-id) nil))] + (await (a/wait-for #(= loading @state) "navigation started")) (reset! state {:route {:data {:name :settings-profile}}}) (t/is (= "File navigation was superseded" (await result)))))))) (t/deftest ^:async open-file-rejects-invalid-identifiers - (let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)}) - (.then (fn [] nil) (fn [error] (.-message error))))] - (t/is (= "Expected a file UUID and a team UUID" (await result))))) + (t/is (= "Expected a file UUID and a team UUID" + (await (message (management/open-file "invalid" #js {:teamId (str team-id)})))))) -(t/deftest ^:async list-projects-returns-active-metadata - (let [project-id (uuid/next) - other-team-id (uuid/next)] - (doseq [[state options expected-team-id] - [[dashboard nil team-id] - [ready #js {:teamId (str other-team-id)} other-team-id]]] - (let [state* (atom state)] - (await - (mock/with-mocks* - {st/state state* - rp/cmd! (mock/stub - (fn [command params] - (t/is (= [:get-projects {:team-id expected-team-id}] - [command params])) - (rx/of [{:id project-id :team-id expected-team-id - :name "Drafts" :is-default true :count 2 :total-count 3} - {:id (uuid/next) :deleted-at (js/Date.)}])))} - (let [projects (await (management/list-projects options))] - (t/is (= [{"id" (str project-id) "teamId" (str expected-team-id) - "name" "Drafts" "isDefault" true "fileCount" 2}] - (js->clj projects))) - (t/is (= state @state*))))))))) - -(t/deftest ^:async list-files-returns-metadata-without-opening-a-file - (let [project-id (uuid/next)] - (doseq [state [dashboard ready]] - (let [state* (atom state)] - (await - (mock/with-mocks* - {st/state state* - rp/cmd! (mock/stub - (fn [command params] - (t/is (= [:get-project-files {:project-id project-id}] - [command params])) - (rx/of [{:id file-id :team-id team-id :project-id project-id - :name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z") - :revn 10}])))} - (let [files (await (management/list-files #js {:projectId (str project-id)}))] - (t/is (= [{"id" (str file-id) "teamId" (str team-id) - "projectId" (str project-id) "name" "Design" - "modifiedAt" "2026-01-02T03:04:05.000Z"}] - (js->clj files))) - (t/is (= state @state*))))))))) - -(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying - (await - (mock/with-mocks* - {st/state (atom {}) - rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))} - (doseq [[result message] - [[(management/list-projects nil) "Expected a team UUID"] - [(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"] - [(management/list-files nil) "Expected a project UUID"] - [(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]] - (t/is (= message (await (.then result (fn [] nil) #(.-message %))))))))) - -(t/deftest ^:async listing-preserves-empty-results-and-backend-errors - (doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)})) - (fn [] (management/list-files #js {:projectId (str (uuid/next))}))]] - (await - (mock/with-mocks* - {rp/cmd! (mock/stub (fn [_ _] (rx/of [])))} - (t/is (= [] (js->clj (await (list-items))))))) - (let [error (js/Error. "Access denied")] - (await - (mock/with-mocks* - {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} - (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) - -(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team - (let [project-id (uuid/next) - other-team-id (uuid/next)] - (doseq [[initial options target-team-id] - [[dashboard #js {:name " Buttons "} team-id] - [ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]] - (let [state (atom initial) - requests (atom []) - project {:id project-id :team-id target-team-id :name "Buttons" - :is-default false :is-pinned false}] - (await - (mock/with-mocks* - {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub - (fn [command params] - (swap! requests conj [command params]) - (->> (rx/of project) (rx/observe-on :async))))} - (let [context (management/create-context) - result (await (.createProject context options))] - (t/is (= {"id" (str project-id) "teamId" (str target-team-id) - "name" "Buttons" "isDefault" false "fileCount" 0} - (js->clj result))) - (t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests)) - (t/is (= (if (= team-id target-team-id) - (assoc-in initial [:projects project-id] (assoc project :count 0)) - initial) - @state))))))))) - -(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation - (let [project-id (uuid/next) - created-id (uuid/next) - modified-at (js/Date. "2026-01-02T03:04:05Z")] - (doseq [initial [dashboard ready]] - (let [initial (assoc initial - :features #{"components/v2" "styles/v2" "plugins/runtime"} - :projects {project-id {:id project-id :team-id team-id :count 2}}) - state (atom initial) - requests (atom []) - file {:id created-id :team-id team-id :project-id project-id - :name "Login" :modified-at modified-at :data {:pages []}}] - (await - (mock/with-mocks* - {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub - (fn [command params] - (swap! requests conj [command params]) - (->> (rx/of file) (rx/observe-on :async))))} - (let [context (management/create-context) - result (await (.createFile context #js {:projectId (str project-id) :name " Login " - :id "ignored" :isShared true}))] - (t/is (= {"id" (str created-id) "teamId" (str team-id) - "projectId" (str project-id) "name" "Login" - "modifiedAt" "2026-01-02T03:04:05.000Z"} - (js->clj result))) - (t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]] - @requests)) - (t/is (= (-> initial - (assoc-in [:files created-id] (dissoc file :data)) - (assoc-in [:recent-files created-id] (dissoc file :data)) - (update-in [:projects project-id :count] inc)) - @state))))))))) - -(t/deftest ^:async creation-rejects-invalid-input-before-querying - (let [project-id (uuid/next) - requests (atom [])] +(t/deftest ^:async listing-returns-team-project-and-file-objects + (let [requests (atom []) + other-id (uuid/next)] (await (mock/with-mocks* {st/state (atom dashboard) + rp/cmd! (cmd-stub requests + (fn [command _] + (rx/of (case command + :get-teams [team] + :get-projects [project {:id other-id :deleted-at (js/Date.)}] + :get-project-files [(dissoc file :team-id)]))))} + (let [context (management/create-context plugin-id) + [team*] (await (.listTeams context)) + [project*] (await (.listProjects context)) + team-project (first (await (.listProjects team*))) + [file*] (await (.listFiles project*))] + (t/is (= {"id" (str team-id) "name" "Team" "isDefault" true} + (props team* ["id" "name" "isDefault"]))) + (t/is (= {"id" (str project-id) "teamId" (str team-id) "name" "Drafts" + "isDefault" true "pinned" false "fileCount" 2} + (props project* ["id" "teamId" "name" "isDefault" "pinned" "fileCount"]))) + (t/is (= (str project-id) (obj/get team-project "id"))) + (t/is (= {"id" (str file-id) "teamId" (str team-id) "projectId" (str project-id) + "name" "Design" "shared" false "modifiedAt" "2026-01-02T03:04:05.000Z"} + (props file* ["id" "teamId" "projectId" "name" "shared" "modifiedAt"]))) + (t/is (= [[:get-teams] + [:get-projects {:team-id team-id}] + [:get-projects {:team-id team-id}] + [:get-project-files {:project-id project-id}]] + @requests))))))) + +(t/deftest ^:async get-file-reads-loaded-files-and-fetches-the-rest + (let [other-id (uuid/next)] + (doseq [[state id expected] + [[ready file-id + [[:get-project {:id project-id}]]] + [dashboard other-id + [[:get-file {:id other-id :features nil}] + [:get-project {:id project-id}]]]]] + (let [requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom state) + rp/cmd! (cmd-stub requests + (fn [command _] + (rx/of (case command + :get-file (assoc file :id other-id :data {}) + :get-project project))))} + (let [file* (await (.getFile (management/create-context plugin-id) (str id)))] + (t/is (= {"id" (str id) "teamId" (str team-id) "projectId" (str project-id)} + (props file* ["id" "teamId" "projectId"]))) + (t/is (= expected @requests))))))))) + +(t/deftest ^:async listing-rejects-invalid-identifiers-and-preserves-backend-errors + (let [requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom {}) + rp/cmd! (cmd-stub requests (fn [_ _] (rx/of nil)))} + (let [context (management/create-context plugin-id)] + (t/is (= "Expected a team UUID" (await (message (.listProjects context nil))))) + (t/is (= "Expected a team UUID" (await (message (.listProjects context #js {:teamId "invalid"}))))) + (t/is (= "Expected a file UUID" (await (message (.getFile context "invalid"))))) + (await (a/settle)) + (t/is (empty? @requests)))))) + (let [error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (mock/stub (fn [& _] (rx/throw error)))} + (let [context (management/create-context plugin-id)] + (t/is (identical? error (await (.then (.listTeams context) (fn [] nil) identity)))) + (t/is (identical? error (await (.then (.listProjects context) (fn [] nil) identity))))))))) + +(t/deftest ^:async teams-are-created + (let [requests (atom []) + events (atom []) + state (atom dashboard) + created {:id (uuid/next) :name "Brand" :is-default false}] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state events) + rp/cmd! (cmd-stub requests (fn [& _] (->> (rx/of created) (rx/observe-on :async))))} + (let [team* (await (.createTeam (management/create-context plugin-id) #js {:name " Brand "}))] + (t/is (= {"id" (str (:id created)) "name" "Brand" "isDefault" false} + (props team* ["id" "name" "isDefault"]))) + (t/is (= [[:create-team "Brand"]] + (mapv (fn [[command params]] [command (:name params)]) @requests))) + (t/is (= 1 (count (filter #(= ::dtm/fetch-teams (ptk/type %)) @events)))) + (t/is (= "Expected a name with 1 to 250 characters" + (await (message (.createTeam (management/create-context plugin-id) #js {:name ""})))))))))) + +(t/deftest ^:async setters-update-values-after-the-backend-succeeds + (let [state (atom (throwing dashboard)) + events (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state events) + rp/cmd! (mock/stub (fn [& _] (->> (rx/of {}) (rx/observe-on :async))))} + (let [team* (management/team-proxy plugin-id team) + project* (management/project-proxy plugin-id project) + file* (management/file-proxy plugin-id file)] + (obj/set! team* "name" " Brand ") + (obj/set! project* "name" "Icons") + (obj/set! project* "pinned" true) + (obj/set! file* "name" "Login") + (obj/set! file* "shared" true) + (t/is (= "Team" (obj/get team* "name"))) + (doseq [event @events] + (await (a/observe (ptk/watch event @state (rx/empty)) + :on-next #(when (fn? %) (swap! state %))))) + (t/is (= "Brand" (obj/get team* "name"))) + (t/is (= "Icons" (obj/get project* "name"))) + (t/is (true? (obj/get project* "pinned"))) + (t/is (= "Login" (obj/get file* "name"))) + (t/is (true? (obj/get file* "shared"))) + (t/is (= 5 (count @events)))))))) + +(t/deftest ^:async stale-object-setter-errors-stay-out-of-the-global-handler + (doseq [[constructor property value original] + [[#(management/team-proxy plugin-id team) "name" "New team" "Team"] + [#(management/project-proxy plugin-id project) "name" "New project" "Drafts"] + [#(management/project-proxy plugin-id project) "pinned" true false] + [#(management/file-proxy plugin-id file) "name" "New file" "Design"] + [#(management/file-proxy plugin-id file) "shared" true false]]] + (let [state (atom (throwing ready)) + events (atom []) + reports (atom []) + escaped (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state events) + rp/cmd! (mock/stub (fn [& _] + (->> (rx/throw (ex-info "http error" {:type :not-found :code :object-not-found})) + (rx/observe-on :async)))) + u/display-not-valid (mock/stub (fn [code value] (swap! reports conj [code value])))} + (let [proxy (constructor)] + (obj/set! proxy property value) + (doseq [event @events] + (await (a/observe (ptk/watch event @state (rx/empty)) + :on-next #(when (fn? %) (swap! state %)) + :on-error #(swap! escaped conj %)))) + (t/is (empty? @escaped) property) + (t/is (= original (obj/get proxy property)) property) + (t/is (= (throwing ready) @state)) + (t/is (= 1 (count @reports)) property))))))) + +(t/deftest setters-reject-invalid-values-and-missing-permissions + (let [state (atom (throwing dashboard)) + events (atom [])] + (with-redefs [st/state state + st/emit! (emit-stub state events)] + (let [team* (management/team-proxy plugin-id team) + project* (management/project-proxy plugin-id project) + file* (management/file-proxy plugin-id file)] + (doseq [[proxy key value] [[team* "name" " "] + [project* "name" (apply str (repeat 251 "a"))] + [project* "pinned" "yes"] + [file* "name" nil] + [file* "shared" 1]]] + (t/is (thrown? js/Error (obj/set! proxy key value)) key)) + (with-redefs [r/check-permission (mock/stub (constantly false))] + (doseq [[proxy key value permission] [[team* "name" "Brand" "manage:teams"] + [project* "name" "Icons" "manage:projects"] + [project* "pinned" true "manage:projects"] + [file* "name" "Login" "content:write"] + [file* "shared" true "library:write"]]] + (t/is (thrown-with-msg? js/Error (re-pattern permission) (obj/set! proxy key value))))) + (t/is (= "Team" (obj/get team* "name"))) + (t/is (= "Design" (obj/get file* "name"))) + (t/is (empty? @events)))))) + +(t/deftest ^:async creation-returns-objects-and-updates-only-the-dashboard + (let [created-project {:id (uuid/next) :team-id team-id :name "Icons" :is-default false} + created-file (assoc file :id (uuid/next) :name "Login" :data {})] + (doseq [initial [dashboard ready]] + (let [state (atom (assoc initial :projects {project-id project})) + requests (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (cmd-stub requests + (fn [command _] + (->> (rx/of (case command + :create-project created-project + :create-file created-file)) + (rx/observe-on :async))))} + (let [project* (await (.createProject (management/team-proxy plugin-id team) #js {:name " Icons "})) + parent (management/project-proxy plugin-id project) + file* (await (.createFile parent #js {:name "Login"}))] + (t/is (= {"name" "Icons" "fileCount" 0} (props project* ["name" "fileCount"]))) + (t/is (= {"name" "Login" "teamId" (str team-id)} (props file* ["name" "teamId"]))) + (t/is (= 2 (obj/get parent "fileCount"))) + (t/is (= [:create-project {:team-id team-id :name "Icons"}] (first @requests))) + (if (= initial dashboard) + (do (t/is (contains? (:projects @state) (:id created-project))) + (t/is (contains? (:files @state) (:id created-file)))) + (t/is (= (assoc ready :projects {project-id project}) @state)))))))))) + +(t/deftest ^:async file-and-project-methods-call-the-backend + (let [requests (atom []) + other-id (uuid/next) + copy (assoc file :id (uuid/next) :name "Design copy") + project-2 (assoc project :id other-id :is-default false)] + (await + (mock/with-mocks* + {st/state (atom dashboard) + st/emit! (mock/stub (fn [_] nil)) + rp/cmd! (cmd-stub requests + (fn [command _] + (rx/of (case command + :duplicate-file copy + :duplicate-project (dissoc project-2 :count :is-pinned) + :get-project-files [file file] + :get-project project-2 + nil))))} + (let [file* (management/file-proxy plugin-id file) + project* (management/project-proxy plugin-id project-2) + team* (management/team-proxy plugin-id (assoc team :id other-id)) + duplicate (await (.duplicate file* #js {:name "Design copy"}))] + (t/is (= "Design copy" (obj/get duplicate "name"))) + (t/is (= {"name" "Drafts" "fileCount" 2 "pinned" false} + (props (await (.duplicate project*)) ["name" "fileCount" "pinned"]))) + (await (.moveTo file* project*)) + (t/is (= (str other-id) (obj/get file* "projectId"))) + (await (.moveTo project* team*)) + (t/is (= (str other-id) (obj/get project* "teamId"))) + (await (.remove file*)) + (await (.remove project*)) + (t/is (= [[:duplicate-file {:file-id file-id :name "Design copy"}] + [:duplicate-project {:project-id other-id}] + [:get-project-files {:project-id other-id}] + [:get-project {:id other-id}] + [:move-files {:ids #{file-id} :project-id other-id}] + [:move-project {:project-id other-id :team-id other-id}] + [:delete-file {:id file-id}] + [:delete-project {:id other-id}]] + @requests))))))) + +(t/deftest ^:async methods-reject-invalid-input-and-missing-permissions + (let [requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (cmd-stub requests (fn [_ _] (rx/of nil)))} + (let [team* (management/team-proxy plugin-id team) + project* (management/project-proxy plugin-id project) + file* (management/file-proxy plugin-id file)] + (t/is (= "Expected a name with 1 to 250 characters" (await (message (.createProject team* #js {:name ""}))))) + (t/is (= "Expected a name with 1 to 250 characters" (await (message (.createFile project* nil))))) + (t/is (= "Expected a name with 1 to 250 characters" (await (message (.duplicate file* #js {:name " "}))))) + (t/is (= "Expected a project" (await (message (.moveTo file* #js {:id "invalid"}))))) + (t/is (= "Expected a team" (await (message (.moveTo project* nil))))) + (with-redefs [r/check-permission (mock/stub (constantly false))] + (doseq [[promise permission] [[(.createTeam (management/create-context plugin-id) #js {:name "Brand"}) "manage:teams"] + [(.listProjects team*) "content:read"] + [(.createProject team* #js {:name "Icons"}) "manage:projects"] + [(.createFile project* #js {:name "Login"}) "content:write"] + [(.duplicate project*) "manage:projects"] + [(.moveTo project* team*) "manage:teams"] + [(.remove project*) "manage:delete"] + [(.open file*) "content:read"] + [(.duplicate file*) "content:write"] + [(.moveTo file* project*) "content:write"] + [(.remove file*) "manage:delete"]]] + (t/is (= (str "Permission " permission " is not granted") (await (message promise)))))) + (await (a/settle)) + (t/is (empty? @requests))))))) + +(t/deftest ^:async file-move-rejects-a-project-that-now-belongs-to-another-team + (let [state (atom ready) + requests (atom []) + target (assoc project :id (uuid/next))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (cmd-stub requests + (fn [_ _] + (->> (rx/of (assoc target :team-id (uuid/next))) + (rx/observe-on :async))))} + (let [file* (management/file-proxy plugin-id file)] + (t/is (= "Cannot move a file to a project in another team" + (await (message (.moveTo file* (management/project-proxy plugin-id target)))))) + (t/is (= (str project-id) (obj/get file* "projectId"))) + (t/is (= (str team-id) (obj/get file* "teamId"))) + (t/is (= ready @state)) + (t/is (= [[:get-project {:id (:id target)}]] @requests))))))) + +(t/deftest ^:async backend-rejections-include-the-type-and-code + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [& _] + (->> (rx/throw (ex-info "http error" {:type :validation + :code :project-already-in-team + :hint "Project is already in this team"})) + (rx/observe-on :async))))} + (t/is (= "Project is already in this team (validation/project-already-in-team)" + (await (message (.moveTo (management/project-proxy plugin-id project) + (management/team-proxy plugin-id team))))))))) + +(t/deftest ^:async file-count-stays-at-its-listing-snapshot-after-creation-and-deletion + (let [state (atom (assoc dashboard :projects {project-id project}))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (mock/stub (fn [command _] + (->> (rx/of (when (= command :create-file) file)) + (rx/observe-on :async))))} + (let [project* (management/project-proxy plugin-id project) + file* (await (.createFile project* #js {:name "Design"}))] + (t/is (= 2 (obj/get project* "fileCount"))) + (t/is (= 3 (get-in @state [:projects project-id :count]))) + (await (.remove file*)) + (t/is (= 2 (obj/get project* "fileCount"))) + (t/is (= 2 (get-in @state [:projects project-id :count])))))))) + +(t/deftest ^:async mcp-can-delete-projects-and-files-without-an-extra-grant + (let [state (atom ready) + requests (atom [])] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (cmd-stub requests (fn [& _] (->> (rx/of nil) (rx/observe-on :async))))} + (doseq [proxy [(management/project-proxy r/mcp-plugin-id project) + (management/file-proxy r/mcp-plugin-id file)]] + (t/is (nil? (await (.remove proxy))))) + (t/is (= [[:delete-project {:id project-id}] [:delete-file {:id file-id}]] @requests)))))) + +(t/deftest ^:async deleted-objects-can-be-listed-and-restored + (let [state (atom ready) + deleted-project (assoc project :deleted-at (js/Date. "2099-01-01")) + deleted-file (assoc file :will-be-deleted-at (js/Date. "2099-01-01"))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (mock/stub (fn [command _] + (->> (rx/of (case command + :get-projects [deleted-project] + :get-team-deleted-files [deleted-file] + ::sse/restore-deleted-team-files #js {:type "end" :data #{file-id}})) + (rx/observe-on :async))))} + (let [[project*] (await (.listDeletedProjects (management/create-context r/mcp-plugin-id))) + [file*] (await (.listDeletedFiles project*))] + (t/is (= (str project-id) (obj/get project* "id"))) + (t/is (= (str file-id) (obj/get file* "id"))) + (t/is (nil? (await (.restore project*)))) + (t/is (nil? (await (.restore file*))))))))) + +(t/deftest ^:async restoration-refreshes-dashboard-projects-and-trash + (doseq [constructor [management/project-proxy management/file-proxy]] + (let [restored? (atom false) + state (atom (assoc dashboard + :projects {project-id (assoc project :deleted-at (js/Date. "2099-01-01"))} + :deleted-files {file-id file}))] + (await + (mock/with-mocks* + {st/state state + st/emit! (emit-stub state (atom [])) + rp/cmd! (mock/stub (fn [command _] + (->> (rx/of (case command + :get-projects [(assoc project :count 1)] + :get-project-files [file] + :get-team-deleted-files (if @restored? [] [file]) + ::sse/restore-deleted-team-files + (do (reset! restored? true) #js {:type "end" :data #{file-id}}))) + (rx/observe-on :async))))} + (await (.restore (constructor plugin-id (if (= constructor management/project-proxy) project file)))) + (t/is (nil? (get-in @state [:projects project-id :deleted-at]))) + (t/is (= 1 (get-in @state [:projects project-id :count]))) + (t/is (= file (get-in @state [:files file-id]))) + (t/is (empty? (:deleted-files @state)))))))) + +(t/deftest ^:async restoration-waits-for-the-trash-operation-to-finish + (let [state (atom ready) + response (rx/subject) + completed? (atom false)] + (await + (mock/with-mocks* + {st/state state rp/cmd! (mock/stub (fn [command params] - (swap! requests conj [command params]) - (->> (rx/of nil) (rx/observe-on :async))))} - (let [context (management/create-context)] - (doseq [options [nil #js {} #js {:name nil} #js {:name 1} - #js {:name ""} #js {:name " \n "} - #js {:name (apply str (repeat 251 "a"))}]] - (t/is (= "Expected a name with 1 to 250 characters" - (await (.then (.createProject context options) (fn [] nil) #(.-message %))))) - (let [options (js/Object.assign #js {:projectId (str project-id)} options)] - (t/is (= "Expected a name with 1 to 250 characters" - (await (.then (.createFile context options) (fn [] nil) #(.-message %))))))) - (doseq [[create options message] - [[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"] - [#(.createFile context %) #js {:name "Login"} "Expected a project UUID"] - [#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]] - (t/is (= message (await (.then (create options) (fn [] nil) #(.-message %))))))) - (await (a/settle)) - (t/is (empty? @requests)))))) + (t/is (= ::sse/restore-deleted-team-files command)) + (t/is (= {:team-id team-id :ids #{file-id}} params)) + (rx/observe-on :async response)))} + (let [promise (.then (.restore (management/file-proxy plugin-id file)) + #(reset! completed? true))] + (rx/push! response #js {:type "progress" :data {:file-id file-id}}) + (await (a/settle)) + (t/is (false? @completed?)) + (rx/push! response #js {:type "end" :data #{file-id}}) + (await promise) + (t/is (true? @completed?))))))) -(t/deftest ^:async creation-preserves-backend-errors-and-state - (doseq [method ["createProject" "createFile"]] - (let [state (atom dashboard) - error (js/Error. "Access denied")] +(t/deftest ^:async restoration-reports-trash-errors-without-changing-the-workspace + (doseq [response [(rx/throw (ex-info "stream exception" {:type :not-found :code :object-not-found})) + (rx/of #js {:type "end" :data #{}}) + (rx/of #js {:type "progress" :data {}})]] + (let [state (atom ready)] (await (mock/with-mocks* {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))} - (let [context (management/create-context) - options #js {:name "Design" :projectId (str (uuid/next))} - result (.call (aget context method) context options)] - (t/is (identical? error (await (.then result (fn [] nil) identity)))) - (t/is (= dashboard @state)))))))) + rp/cmd! (mock/stub (fn [_ _] (rx/observe-on :async response)))} + (t/is (string? (await (message (.restore (management/file-proxy plugin-id file)))))) + (t/is (= ready @state))))))) -(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation - (doseq [method ["createProject" "createFile"]] - (let [state (atom dashboard) - response (rx/subject) - other (assoc dashboard :current-team-id (uuid/next)) - item-id (uuid/next) - item {:id item-id :team-id team-id :project-id (uuid/next) - :name "Design" :modified-at (js/Date.)}] - (await - (mock/with-mocks* - {st/state state - st/emit! (mock/stub (fn [event] (swap! state event))) - rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))} - (let [context (management/create-context) - result (.call (aget context method) context - #js {:name "Design" :projectId (str (:project-id item))})] - (reset! state other) - (rx/push! response item) - (t/is (= (str item-id) (.-id (await result)))) - (t/is (= other @state)))))))) +(t/deftest ^:async projects-without-recoverable-files-cannot-be-restored + (let [state (atom ready)] + (await + (mock/with-mocks* + {st/state state + rp/cmd! (mock/stub (fn [command _] + (t/is (= :get-team-deleted-files command)) + (->> (rx/of []) (rx/observe-on :async))))} + (t/is (= "Cannot restore a project without recoverable files" + (await (message (.restore (management/project-proxy plugin-id project)))))))))) diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs index 49c387d4bc..a4399ab6af 100644 --- a/frontend/test/frontend_tests/plugins/register_test.cljs +++ b/frontend/test/frontend_tests/plugins/register_test.cljs @@ -12,6 +12,12 @@ [cljs.test :as t :include-macros true] [frontend-tests.helpers.mock :as mock])) +(t/deftest mcp-has-default-management-permissions + (t/is (preg/check-permission preg/mcp-plugin-id "manage:delete")) + (t/is (preg/check-permission preg/mcp-plugin-id "manage:teams")) + (t/is (preg/check-permission preg/mcp-plugin-id "content:write")) + (t/is (not (preg/check-permission preg/mcp-plugin-id "unknown:permission")))) + (defn- record-cmd-mock "Mock rp/cmd! that records calls in mock/rpc-calls and answers with (response-fn cmd params)." diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 0be9173e33..68e9f34c95 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8261,6 +8261,18 @@ msgstr "Read your libraries and assets." msgid "workspace.plugins.permissions.library-write" msgstr "Read and modify your libraries and assets." +#: src/app/main/ui/workspace/plugins.cljs:360 +msgid "workspace.plugins.permissions.manage-delete" +msgstr "Delete your files and projects." + +#: src/app/main/ui/workspace/plugins.cljs:346 +msgid "workspace.plugins.permissions.manage-projects" +msgstr "Create, rename, pin and duplicate your projects." + +#: src/app/main/ui/workspace/plugins.cljs:353 +msgid "workspace.plugins.permissions.manage-teams" +msgstr "See, create and rename your teams, and move projects between them." + #: src/app/main/ui/workspace/plugins.cljs:387 msgid "workspace.plugins.permissions.title" msgstr "'%s' PLUGIN WANTS ACCESS TO:" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 5715b6bba1..103b2725b3 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8356,6 +8356,18 @@ msgstr "Leer la información de sus bibliotecas y recursos." msgid "workspace.plugins.permissions.library-write" msgstr "Leer y modificar la información de sus bibliotecas y recursos." +#: src/app/main/ui/workspace/plugins.cljs:360 +msgid "workspace.plugins.permissions.manage-delete" +msgstr "Eliminar tus archivos y proyectos." + +#: src/app/main/ui/workspace/plugins.cljs:346 +msgid "workspace.plugins.permissions.manage-projects" +msgstr "Crear, renombrar, fijar y duplicar tus proyectos." + +#: src/app/main/ui/workspace/plugins.cljs:353 +msgid "workspace.plugins.permissions.manage-teams" +msgstr "Ver, crear y renombrar tus equipos, y mover proyectos entre ellos." + #: src/app/main/ui/workspace/plugins.cljs:387 msgid "workspace.plugins.permissions.title" msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:" diff --git a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts index 40295aee4a..41323fbdbc 100644 --- a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts +++ b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { ExecuteCodeTaskConsole } from "./task-handlers/ExecuteCodeTaskHandler.ts"; +import { ExecuteCodeTaskConsole } from "./ExecuteCodeTaskConsole.ts"; test("formats plain objects and primitives", () => { const console = new ExecuteCodeTaskConsole(); diff --git a/mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts new file mode 100644 index 0000000000..b17e8c3b87 --- /dev/null +++ b/mcp/packages/plugin/src/ExecuteCodeTaskConsole.ts @@ -0,0 +1,171 @@ +/** + * Console implementation that captures all log output for code execution. + * + * Provides the same interface as the native console object but appends + * all output to an internal log string that can be retrieved. + */ +export class ExecuteCodeTaskConsole { + /** + * Accumulated log output from all console method calls. + */ + private logOutput: string = ""; + + /** + * Resets the accumulated log output to empty string. + * Should be called before each code execution to start with clean logs. + */ + resetLog(): void { + this.logOutput = ""; + } + + /** + * Gets the accumulated log output from all console method calls. + * @returns The complete log output as a string + */ + getLog(): string { + return this.logOutput; + } + + /** + * Formats one argument of a captured console call. + * Errors have no enumerable fields, and values such as circular objects + * cannot be serialized to JSON, so fall back to their string form. + */ + private formatLogArgument(arg: unknown): string { + if (arg instanceof Error) { + return String(arg); + } + if (typeof arg === "object") { + try { + return JSON.stringify(arg, null, 2); + } catch { + return String(arg); + } + } + return String(arg); + } + + /** + * Appends a formatted message to the log output. + * @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR") + * @param args - Arguments to log, will be stringified and joined + */ + private appendToLog(level: string, ...args: any[]): void { + const message = args.map((arg) => this.formatLogArgument(arg)).join(" "); + this.logOutput += `[${level}] ${message}\n`; + } + + /** + * Logs a message to the captured output. + */ + log(...args: any[]): void { + this.appendToLog("LOG", ...args); + } + + /** + * Logs a warning message to the captured output. + */ + warn(...args: any[]): void { + this.appendToLog("WARN", ...args); + } + + /** + * Logs an error message to the captured output. + */ + error(...args: any[]): void { + this.appendToLog("ERROR", ...args); + } + + /** + * Logs an informational message to the captured output. + */ + info(...args: any[]): void { + this.appendToLog("INFO", ...args); + } + + /** + * Logs a debug message to the captured output. + */ + debug(...args: any[]): void { + this.appendToLog("DEBUG", ...args); + } + + /** + * Logs a message with trace information to the captured output. + */ + trace(...args: any[]): void { + this.appendToLog("TRACE", ...args); + } + + /** + * Logs a table to the captured output (simplified as JSON). + */ + table(data: any): void { + this.appendToLog("TABLE", data); + } + + /** + * Starts a timer (simplified implementation that just logs). + */ + time(label?: string): void { + this.appendToLog("TIME", `Timer started: ${label || "default"}`); + } + + /** + * Ends a timer (simplified implementation that just logs). + */ + timeEnd(label?: string): void { + this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`); + } + + /** + * Logs messages in a group (simplified to just log the label). + */ + group(label?: string): void { + this.appendToLog("GROUP", label || ""); + } + + /** + * Logs messages in a collapsed group (simplified to just log the label). + */ + groupCollapsed(label?: string): void { + this.appendToLog("GROUP_COLLAPSED", label || ""); + } + + /** + * Ends the current group (simplified implementation). + */ + groupEnd(): void { + this.appendToLog("GROUP_END", ""); + } + + /** + * Clears the console (no-op in this implementation since we want to capture logs). + */ + clear(): void { + // intentionally empty - we don't want to clear captured logs + } + + /** + * Counts occurrences of calls with the same label (simplified implementation). + */ + count(label?: string): void { + this.appendToLog("COUNT", label || "default"); + } + + /** + * Resets the count for a label (simplified implementation). + */ + countReset(label?: string): void { + this.appendToLog("COUNT_RESET", label || "default"); + } + + /** + * Logs an assertion (simplified to just log if condition is false). + */ + assert(condition: boolean, ...args: any[]): void { + if (!condition) { + this.appendToLog("ASSERT", ...args); + } + } +} diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index b2fff14f05..43bfd37e35 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -1,178 +1,7 @@ import { Task, TaskHandler } from "../TaskHandler"; import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData } from "../../../common/src"; import { PenpotUtils } from "../PenpotUtils.ts"; - -/** - * Console implementation that captures all log output for code execution. - * - * Provides the same interface as the native console object but appends - * all output to an internal log string that can be retrieved. - */ -export class ExecuteCodeTaskConsole { - /** - * Accumulated log output from all console method calls. - */ - private logOutput: string = ""; - - /** - * Resets the accumulated log output to empty string. - * Should be called before each code execution to start with clean logs. - */ - resetLog(): void { - this.logOutput = ""; - } - - /** - * Gets the accumulated log output from all console method calls. - * @returns The complete log output as a string - */ - getLog(): string { - return this.logOutput; - } - - /** - * Formats one argument of a captured console call. - * Errors have no enumerable fields, and values such as circular objects - * cannot be serialized to JSON, so fall back to their string form. - */ - private formatLogArgument(arg: unknown): string { - if (arg instanceof Error) { - return String(arg); - } - if (typeof arg === "object") { - try { - return JSON.stringify(arg, null, 2); - } catch { - return String(arg); - } - } - return String(arg); - } - - /** - * Appends a formatted message to the log output. - * @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR") - * @param args - Arguments to log, will be stringified and joined - */ - private appendToLog(level: string, ...args: any[]): void { - const message = args.map((arg) => this.formatLogArgument(arg)).join(" "); - this.logOutput += `[${level}] ${message}\n`; - } - - /** - * Logs a message to the captured output. - */ - log(...args: any[]): void { - this.appendToLog("LOG", ...args); - } - - /** - * Logs a warning message to the captured output. - */ - warn(...args: any[]): void { - this.appendToLog("WARN", ...args); - } - - /** - * Logs an error message to the captured output. - */ - error(...args: any[]): void { - this.appendToLog("ERROR", ...args); - } - - /** - * Logs an informational message to the captured output. - */ - info(...args: any[]): void { - this.appendToLog("INFO", ...args); - } - - /** - * Logs a debug message to the captured output. - */ - debug(...args: any[]): void { - this.appendToLog("DEBUG", ...args); - } - - /** - * Logs a message with trace information to the captured output. - */ - trace(...args: any[]): void { - this.appendToLog("TRACE", ...args); - } - - /** - * Logs a table to the captured output (simplified as JSON). - */ - table(data: any): void { - this.appendToLog("TABLE", data); - } - - /** - * Starts a timer (simplified implementation that just logs). - */ - time(label?: string): void { - this.appendToLog("TIME", `Timer started: ${label || "default"}`); - } - - /** - * Ends a timer (simplified implementation that just logs). - */ - timeEnd(label?: string): void { - this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`); - } - - /** - * Logs messages in a group (simplified to just log the label). - */ - group(label?: string): void { - this.appendToLog("GROUP", label || ""); - } - - /** - * Logs messages in a collapsed group (simplified to just log the label). - */ - groupCollapsed(label?: string): void { - this.appendToLog("GROUP_COLLAPSED", label || ""); - } - - /** - * Ends the current group (simplified implementation). - */ - groupEnd(): void { - this.appendToLog("GROUP_END", ""); - } - - /** - * Clears the console (no-op in this implementation since we want to capture logs). - */ - clear(): void { - // intentionally empty - we don't want to clear captured logs - } - - /** - * Counts occurrences of calls with the same label (simplified implementation). - */ - count(label?: string): void { - this.appendToLog("COUNT", label || "default"); - } - - /** - * Resets the count for a label (simplified implementation). - */ - countReset(label?: string): void { - this.appendToLog("COUNT_RESET", label || "default"); - } - - /** - * Logs an assertion (simplified to just log if condition is false). - */ - assert(condition: boolean, ...args: any[]): void { - if (!condition) { - this.appendToLog("ASSERT", ...args); - } - } -} +import { ExecuteCodeTaskConsole } from "../ExecuteCodeTaskConsole.ts"; /** * Task handler for executing JavaScript code in the plugin context. diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 7fc050cec7..32460c4315 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5037,24 +5037,131 @@ WorkspaceContext: ``` readonly teamId: string | null ``` -ProjectSummary: +Team: overview: |- - Interface ProjectSummary - ======================== + Interface Team + ============== - Metadata snapshot of an accessible, non-deleted project. + A team the user belongs to. Requires `manage:teams`. + + Values are a snapshot taken when the team was listed; changes made through + this object update it. + Property assignments run asynchronously and update values after success. + Backend failures are logged to the console; assignments cannot be awaited. ``` - interface ProjectSummary { + interface Team { id: string; - teamId: string; - name: string; isDefault: boolean; - fileCount: number; + name: string; + listProjects(): Promise; + listDeletedProjects(): Promise; + listDeletedFiles(): Promise; + createProject(options: { name: string }): Promise; } ``` - Referenced by: PenpotMgmt + Referenced by: PenpotMgmt, Project + members: + Properties: + id: |- + ``` + readonly id: string + ``` + isDefault: |- + ``` + readonly isDefault: boolean + ``` + + Whether this is the user's personal team. + name: |- + ``` + name: string + ``` + + The team name. Assigning renames the team; requires `manage:teams`. + Trims the name and requires 1 to 250 characters. + Methods: + listProjects: |- + ``` + listProjects(): Promise + ``` + + Lists the team's projects. Requires `content:read`. + + Returns Promise + + Example + ``` + const [team] = await penpotMgmt.listTeams();const projects = await team.listProjects(); + ``` + listDeletedProjects: |- + ``` + listDeletedProjects(): Promise + ``` + + Lists projects still recoverable from Trash. Requires `content:read`. + + Returns Promise + listDeletedFiles: |- + ``` + listDeletedFiles(): Promise + ``` + + Lists files still recoverable from Trash. Requires `content:read`. + + Returns Promise + createProject: |- + ``` + createProject(options: { name: string }): Promise + ``` + + Creates a project in the team. Requires `manage:projects`. + + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation. + + Parameters + + * options: { name: string } + + Returns Promise + + Example + ``` + const project = await team.createProject({ name: 'Design system' }); + ``` +Project: + overview: |- + Interface Project + ================= + + A project in the dashboard. + + Values are a snapshot taken when the project was listed; changes made through + this object update it. + Property assignments run asynchronously and update values after success. + Backend failures are logged to the console; assignments cannot be awaited. + + ``` + interface Project { + id: string; + teamId: string; + isDefault: boolean; + fileCount: number; + name: string; + pinned: boolean; + listFiles(): Promise; + listDeletedFiles(): Promise; + restore(): Promise; + createFile(options: { name: string }): Promise; + duplicate(options?: { name?: string }): Promise; + moveTo(team: Team): Promise; + remove(): Promise; + } + ``` + + Referenced by: PenpotMgmt, Project, ProjectFile, Team members: Properties: id: |- @@ -5065,10 +5172,6 @@ ProjectSummary: ``` readonly teamId: string ``` - name: |- - ``` - readonly name: string - ``` isDefault: |- ``` readonly isDefault: boolean @@ -5079,24 +5182,138 @@ ProjectSummary: ``` readonly fileCount: number ``` -FileSummary: + + Number of files when the project was listed; file operations do not update it. + name: |- + ``` + name: string + ``` + + The project name. Assigning renames the project; requires `manage:projects`. + Trims the name and requires 1 to 250 characters. + pinned: |- + ``` + pinned: boolean + ``` + + Whether the project is pinned in the sidebar. Assigning requires `manage:projects`. + Methods: + listFiles: |- + ``` + listFiles(): Promise + ``` + + Lists the files in the project. Requires `content:read`. + + Returns Promise + + Example + ``` + const files = await project.listFiles(); + ``` + listDeletedFiles: |- + ``` + listDeletedFiles(): Promise + ``` + + Lists the project's files still recoverable from Trash. Requires `content:read`. + + Returns Promise + restore: |- + ``` + restore(): Promise + ``` + + Restores the project through its files using the dashboard's Trash operation. + Requires `manage:projects` and team edit access. Does not require `manage:delete`. + Rejects if the project has no recoverable files, including empty projects. + + Returns Promise + createFile: |- + ``` + createFile(options: { name: string }): Promise + ``` + + Creates a file with an initial page in the project. Requires `content:write`. + + * Trims `name` and requires 1 to 250 characters. + * Does not open the file. Use `ProjectFile.open` to edit it. + + Parameters + + * options: { name: string } + + Returns Promise + + Example + ``` + const file = await project.createFile({ name: 'Login' });await file.open(); + ``` + duplicate: |- + ``` + duplicate(options?: { name?: string }): Promise + ``` + + Duplicates the project with all its files. Requires `manage:projects`. + + `name` defaults to the name the backend chooses. + + Parameters + + * options: { name?: string } + + Returns Promise + moveTo: |- + ``` + moveTo(team: Team): Promise + ``` + + Moves the project to another team. Requires `manage:teams`. + + Parameters + + * team: Team + + Returns Promise + remove: |- + ``` + remove(): Promise + ``` + + Moves the project and its files to Trash. Requires `manage:delete`. + They can be restored during the retention period. Cannot permanently delete. + + Returns Promise +ProjectFile: overview: |- - Interface FileSummary + Interface ProjectFile ===================== - Metadata snapshot of an accessible, non-deleted file. + A file in the dashboard, which may or may not be open. + + Values are a snapshot taken when the file was listed; changes made through + this object update it. To read or edit its content, `open` it and use + `penpot.currentFile`. + Property assignments run asynchronously and update values after success. + Backend failures are logged to the console; assignments cannot be awaited. ``` - interface FileSummary { + interface ProjectFile { id: string; teamId: string; projectId: string; - name: string; modifiedAt: string; + name: string; + shared: boolean; + open(): Promise; + restore(): Promise; + moveTo(project: Project): Promise; + duplicate(options?: { name?: string }): Promise; + remove(): Promise; } ``` - Referenced by: PenpotMgmt + Referenced by: PenpotMgmt, Project, ProjectFile members: Properties: id: |- @@ -5111,16 +5328,84 @@ FileSummary: ``` readonly projectId: string ``` - name: |- - ``` - readonly name: string - ``` modifiedAt: |- ``` readonly modifiedAt: string ``` Last modification time as an ISO 8601 string in UTC. + name: |- + ``` + name: string + ``` + + The file name. Assigning renames the file; requires `content:write`. + Trims the name and requires 1 to 250 characters. + shared: |- + ``` + shared: boolean + ``` + + Whether the file is shared as a library with its team. Assigning requires + `library:write`. A shared file is listed by + `penpot.library.availableLibraries()`; unsharing copies its assets into + every file that uses it and disconnects them. + Methods: + open: |- + ``` + open(): Promise + ``` + + Opens the file in this tab and resolves once its workspace and page are + ready. Requires `content:read`. + + Returns Promise + restore: |- + ``` + restore(): Promise + ``` + + Restores the file using the dashboard's Trash operation, including its parent + project if deleted. Requires `content:write` and team edit access. + Rejects if the file no longer exists. Does not require `manage:delete`. + + Returns Promise + moveTo: |- + ``` + moveTo(project: Project): Promise + ``` + + Moves the file to another project of the same team. Requires `content:write`. + Rejects a destination that currently belongs to another team. + + Parameters + + * project: Project + + Returns Promise + duplicate: |- + ``` + duplicate(options?: { name?: string }): Promise + ``` + + Duplicates the file in its project. Requires `content:write`. + + `name` defaults to the name the backend chooses. + + Parameters + + * options: { name?: string } + + Returns Promise + remove: |- + ``` + remove(): Promise + ``` + + Moves the file to Trash. Requires `manage:delete`. + It can be restored during the retention period. Cannot permanently delete. + + Returns Promise PenpotMgmt: overview: |- Interface PenpotMgmt @@ -5128,18 +5413,22 @@ PenpotMgmt: Application management API exposed as `penpotMgmt` to global plugins. - * Discovery and navigation require `content:read`; creation requires `content:write`. - * Available in the dashboard and file workspaces. - * Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. + * Lists teams, projects and files, and opens files, from the dashboard or a + file workspace. Teams, projects and files are objects with their own + properties and methods. + * Listing and opening require `content:read`; teams require `manage:teams`. + * File operations on `penpot` require a ready workspace. * Reacquire file, page and shape objects after changing files. ``` interface PenpotMgmt { workspace: WorkspaceContext; - listProjects(options?: { teamId?: string }): Promise; - listFiles(options: { projectId: string }): Promise; - createProject(options: { name: string; teamId?: string }): Promise; - createFile(options: { name: string; projectId: string }): Promise; + listTeams(): Promise; + createTeam(options: { name: string }): Promise; + listProjects(options?: { teamId?: string }): Promise; + listDeletedProjects(options?: { teamId?: string }): Promise; + listDeletedFiles(options?: { teamId?: string }): Promise; + getFile(fileId: string): Promise; openFile(fileId: string, options?: { teamId?: string }): Promise; on( type: "workspacechange", @@ -5159,92 +5448,101 @@ PenpotMgmt: Current workspace availability and metadata; usable for file operations when status is `ready`. Methods: + listTeams: |- + ``` + listTeams(): Promise + ``` + + Lists the teams of the user. Requires `manage:teams`. + + Returns Promise + + Example + ``` + const teams = await penpotMgmt.listTeams(); + ``` + createTeam: |- + ``` + createTeam(options: { name: string }): Promise + ``` + + Creates a team with the user as owner. Requires `manage:teams`. + + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation. + + Parameters + + * options: { name: string } + + Returns Promise + + Example + ``` + const team = await penpotMgmt.createTeam({ name: 'Marketing' }); + ``` listProjects: |- ``` - listProjects(options?: { teamId?: string }): Promise + listProjects(options?: { teamId?: string }): Promise ``` - Lists accessible, non-deleted projects as metadata snapshots. + Lists the projects of a team. Requires `content:read`. * `teamId` defaults to the current team. - * Queries the backend without opening a file or changing navigation. * Rejects invalid identifiers and backend access errors. Parameters * options: { teamId?: string } - Returns Promise + Returns Promise Example ``` const projects = await penpotMgmt.listProjects(); ``` - listFiles: |- + listDeletedProjects: |- ``` - listFiles(options: { projectId: string }): Promise + listDeletedProjects(options?: { teamId?: string }): Promise ``` - Lists accessible, non-deleted files in the specified project as metadata snapshots. - - * Requires an explicit `projectId`. - * Queries the backend without opening a file or changing navigation. - * Rejects invalid identifiers and backend access errors. + Lists projects still recoverable from Trash. Requires `content:read`. + `teamId` defaults to the current team. Use `Project.restore` to recover them. Parameters - * options: { projectId: string } + * options: { teamId?: string } - Returns Promise - - Example + Returns Promise + listDeletedFiles: |- ``` - const files = await penpotMgmt.listFiles({ projectId: project.id }); - ``` - createProject: |- - ``` - createProject(options: { name: string; teamId?: string }): Promise + listDeletedFiles(options?: { teamId?: string }): Promise ``` - Creates a project and returns its metadata snapshot. Requires `content:write`. - - * `teamId` defaults to the current team. - * Trims `name` and requires 1 to 250 characters. - * Does not change navigation or open a file. - * Rejects invalid input, backend access errors and quota errors. + Lists files still recoverable from Trash. Requires `content:read`. + `teamId` defaults to the current team. Use `ProjectFile.restore` to recover them. Parameters - * options: { name: string; teamId?: string } + * options: { teamId?: string } - Returns Promise - - Example + Returns Promise + getFile: |- ``` - const project = await penpotMgmt.createProject({ name: 'Design system' }); - ``` - createFile: |- - ``` - createFile(options: { name: string; projectId: string }): Promise + getFile(fileId: string): Promise ``` - Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. - - * Requires an explicit `projectId`. - * Trims `name` and requires 1 to 250 characters. - * Does not open the file or change navigation. Use `openFile` to edit it. - * Rejects invalid input, backend access errors and quota errors. + Loads a file by id, open or not. Requires `content:read`. Parameters - * options: { name: string; projectId: string } + * fileId: string - Returns Promise + Returns Promise Example ``` - const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); - await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + const file = await penpotMgmt.getFile(penpotMgmt.workspace.fileId!);file.name = 'Checkout'; ``` openFile: |- ``` diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 437c58c7ab..66c2c642da 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -435,12 +435,18 @@ For many tasks, it can be critical to visually inspect the design. Remember to u # The `penpotMgmt` Object This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. -It allows you to discover, create and navigate to design projects and files the user has access to. -Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`). -Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`. -Both creation methods require `content:write` and return metadata without changing the current view. -To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. +Teams (`Team`), projects (`Project`) and files (`ProjectFile`) are objects with their own properties and methods: + * `await penpotMgmt.listProjects()` lists the projects of the current team; `await penpotMgmt.listTeams()` lists all teams. + * `await project.listFiles()` lists a project's files; `await penpotMgmt.getFile(id)` loads any file by id, e.g. `penpotMgmt.workspace.fileId`. + * Create with `await team.createProject({ name: "Design system" })` and `await project.createFile({ name: "Login" })`. + * Rename by assigning `name`; pin a project with `project.pinned = true`; share a file as a team library with `file.shared = true`. + * `moveTo`, `duplicate` and `remove` move, copy and delete projects and files; `penpotMgmt.createTeam({ name })` creates teams. Team deletion is not exposed. + * Project and file `remove()` moves them to Trash. There is no API for their permanent deletion. Discover recoverable objects with `await penpotMgmt.listDeletedProjects()` and `await penpotMgmt.listDeletedFiles()` (optionally pass `{ teamId }`), or `team.listDeletedProjects()`, `team.listDeletedFiles()` and `project.listDeletedFiles()`. + * Restore with `await project.restore()` or `await file.restore()` before the retention period expires. Restoration uses the dashboard's Trash operation and requires team edit access. Project restoration restores its recoverable files and rejects if none remain, including empty projects. File restoration also restores its deleted parent project. + * None of these change the current view. To edit a file, call `await file.open()` (or `await penpotMgmt.openFile(fileId)`) and reacquire file, page and shape objects. + * Connect a shared library to the open file with `penpot.library.connectLibrary(file.id)`. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. +Only rename teams or rename, move, delete or restore projects and files when the user asks for it. If `penpotMgmt` is `null`, file management is unavailable and only the file that is open in Penpot can be used; ask the user to open another file if needed. diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index ac3ecf08a0..a6bbcde71f 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🚀 Features -- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. +- **plugin-types, plugins-runtime:** `penpotMgmt` returns `Team`, `Project` and `ProjectFile` objects. Assigning `name` renames teams, projects and files, `project.pinned` pins a project and `file.shared` shares a file as a team library; `createProject`, `createFile`, `moveTo`, `duplicate`, `remove` and `restore` live on the objects. Added `penpotMgmt.listTeams`, `penpotMgmt.createTeam`, `penpotMgmt.getFile`, `listDeletedProjects` and `listDeletedFiles`, and the `manage:projects`, `manage:teams` and `manage:delete` permissions. Restoration uses the dashboard's Trash operation; projects need recoverable files and empty projects cannot be restored; the API exposes no permanent deletion method. Team deletion is not exposed. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. @@ -11,7 +11,9 @@ ### 🩹 Fixes +- **plugin-types, plugin-runtime:** Dashboard property setters now update values after backend success and log failures without interrupting the workspace. File moves reject projects in other teams, backend rejections include their type and code, and `project.fileCount` stays at its listing snapshot. Duplicated projects include their file count and pinned state. - **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup. +- **plugins-runtime**: `library.connectLibrary()` now resolves once the library's assets are loaded; it used to resolve with an empty library the first time. `library.connected` and `library.availableLibraries()` ignore files opened earlier in the same tab, which used to show up as connected and be left out of the available libraries. - **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction. - **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape. - **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported. diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md index 5ea524b484..a04657cfff 100644 --- a/plugins/apps/plugin-api-test-suite/README.md +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -17,9 +17,10 @@ fully before writing any test. ## The one rule that matters most -> **Always call the API through `ctx.penpot`, never the global `penpot`.** +> **Always call the API through `ctx.penpot` and `ctx.penpotMgmt`, never the +> globals `penpot` and `penpotMgmt`.** -`ctx.penpot` is a recording proxy. Calls made through it are what count towards +`ctx.penpot` and `ctx.penpotMgmt` are recording proxies. Calls made through it are what count towards coverage and are correctly attributed to the right interface. Calls on the global `penpot` still work but are invisible to coverage. Same for shapes: operate on the objects returned by `ctx.penpot.*` (and on `ctx.board`), not on objects obtained @@ -31,7 +32,9 @@ From `plugins/`: - Dev server: `pnpm run start:plugin:api-test-suite` (serves on port 4202). - In Penpot: open the Plugin Manager (Ctrl+Alt+P) and install - `http://localhost:4202/manifest.json`. + `http://localhost:4202/manifest.json`. The suite is a global plugin + (`"scope": "global"`), so the install dialog asks for the `allow:global` + permission. It keeps running while tests move between files. - Alternatively, a one-shot `pnpm --filter plugin-api-test-suite run build` output is served by the devenv alongside the other bundled plugins, at `https://localhost:3449/plugins/plugin-api-test-suite/manifest.json` (no hot @@ -62,6 +65,8 @@ E2E_LOGIN_EMAIL=… E2E_LOGIN_PASSWORD=… \ - It builds `headless.js`, logs in, creates a scratch file, injects the test bundle, and prints per-test results + the coverage report. +- After the run it deletes the projects the tests created (see + [Working with other files](#working-with-other-files)). - Exit code is non-zero iff any test failed (coverage does not affect it). - Optional env: `PENPOT_BASE_URL` (default `https://localhost:3449`). Against a local devenv with a self-signed certificate, prefix the command with @@ -214,15 +219,50 @@ committing. - `ctx.penpot` — the recording proxy over the real `penpot` global. Use it for every API call. +- `ctx.penpotMgmt` — the recording proxy over the real `penpotMgmt` global, for + teams, projects and files (see [Working with other files](#working-with-other-files)). - `ctx.board` — a **fresh scratch `Board`** created for this test and **removed automatically afterwards**. Append shapes you create to it (`ctx.board.appendChild(shape)`) so the user's canvas is left clean. Do not rely on it persisting between tests. +- `ctx.fixture(key, create)` — returns the value `create` resolves to, created + the first time `key` is used in the run and reused by later tests of the same + run. Keep shared fixtures here rather than in module variables: the plugin UI + keeps modules between runs, so a module-level cache would leak state from one + run into the next. -The runner also resets shared state between tests: the selection is cleared and the -active page is restored to whatever was active when the run started (both through -the raw `penpot`, so they aren't credited toward coverage). A test that changes the -active page therefore won't leak into later tests. +The runner also resets shared state between tests: it reopens the file the run +started in if a test left another one open, restores the page that was active +when the run started and clears the selection (all through the raw globals, so +they aren't credited toward coverage). A test that changes the active page or +file therefore won't leak into later tests. + +### Working with other files + +The suite runs with global scope, so tests can reach teams, projects and files +through `ctx.penpotMgmt`. They are objects like the rest of the API: `Team` +(`listTeams()`), `Project` (`listProjects()`, `team.createProject()`) and +`ProjectFile` (`project.listFiles()`, `project.createFile()`, `getFile(id)`), +with `name`/`pinned`/`shared` properties and `open`, `moveTo`, `duplicate` and +`remove` methods. `management.test.ts` shows the patterns: + +- Put every team and project a test creates under a name from + `temporaryName()` (`src/framework/temporary.ts`). Tests delete the teams they + create; the live CI driver deletes the teams and projects with that prefix + after the run. Runs from the plugin UI leave the projects behind. The tests + need nothing set up beforehand. +- Assigning `name`, `pinned` or `shared` updates the object at once and saves + in the background. To check the saved result, list again inside + `waitForAsync()` (`src/tests/wait.ts`). +- Shape, page, file and library objects taken before `file.open()` (or + `openFile`) are stale afterwards; look them up again in the new file. + `ctx.board` belongs to the file the test started in, so only use it there. +- While a file opens, `penpot.currentFile`, `currentPage` and `root` are `null` + and file operations throw. `open` resolves once the file is ready. +- These tests need the real backend, so tag them `skipIfMocked`. +- The per-test timeout is 15s; opening a file takes about half a second + locally. Build expensive fixtures (such as a shared library) once per run with + `ctx.fixture`, as `sharedLibrary()` does. ### Sync or async @@ -318,8 +358,8 @@ pnpm --filter plugin-api-test-suite run gen:api - `createText(str)` returns `Text | null` — guard the result (`if (text) { … }`). - `width`/`height` are read-only; use `resize(w, h)`. `x`/`y` are writable. - The plugin manifest already requests broad permissions (`content:*`, - `library:*`, `user:read`, `comment:*`, `allow:downloads`, `allow:localstorage`), - so most of the API is callable from tests without changes. + `library:*`, `user:read`, `comment:*`, `allow:downloads`, `allow:localstorage`, + `allow:global`), so most of the API is callable from tests without changes. - The runner sets `throwValidationErrors = true` and `naturalChildOrdering = true`, so invalid API usage throws (surfacing as a red test) and `children` is always in z-index order. @@ -405,8 +445,6 @@ valid"); `fills-strokes.test.ts` pins this with a `toThrow`. ### External state / not reachable headless - **`ActiveUser.position/zoom`** — needs a second collaborator in the file. -- **`LibrarySummary.*`, `LibraryContext.connectLibrary`** — need a published shared - library. - **`FileVersion.restore`, `Penpot.closePlugin`, `Penpot.ui`, `Context.openViewer`** — tear down or navigate away from the running plugin/workspace. - **`FileVersion.pin`** — only converts a _system_ autosave to a permanent version; @@ -414,14 +452,16 @@ valid"); `fills-strokes.test.ts` pins this with a `toThrow`. rejects. - **`Context.addListener/removeListener`** — omitted from the `penpot` global (`Omit`), so unreachable via `penpot`. -- **`EventsMap` events `pagechange/filechange/themechange/contentsave/finish`** — - can't be triggered deterministically in the headless runner. +- **`EventsMap` events `pagechange/themechange/contentsave`** — can't be + triggered deterministically in the headless runner. `filechange` and `finish` + are exercised in `management.test.ts` by switching files. ## Checklist before finishing - [ ] Test file is `src/tests/.test.ts` and uses `test(...)` + `expect`, ideally wrapped in a `describe('', …)`. -- [ ] All API calls go through `ctx.penpot`; shapes are appended to `ctx.board`. +- [ ] All API calls go through `ctx.penpot` / `ctx.penpotMgmt`; shapes are + appended to `ctx.board`. - [ ] Created shapes don't leak (rely on the scratch board cleanup; don't touch the user's existing content). - [ ] Lint/format/typecheck pass: @@ -438,6 +478,7 @@ valid"); `fills-strokes.test.ts` pins this with a `toThrow`. - `src/framework/static-coverage.ts` — the statically-covered allowlist. - `src/framework/expect.ts` — the assertion library. - `src/framework/types.ts` — `TestContext`, `TestResult`, `CoverageReport`, etc. +- `src/framework/temporary.ts` — name prefix for temporary projects, shared with the CI driver. - `tools/gen-api-surface.ts` — generates `src/generated/api-surface.json`. - `src/plugin.ts` (sandbox), `src/ui.ts` (iframe), `src/model.ts` (messages). - `src/ci/headless.ts` + `ci/run-ci.ts` — CI path. diff --git a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts index 559e6f0aea..acf6904299 100644 --- a/plugins/apps/plugin-api-test-suite/ci/run-ci.ts +++ b/plugins/apps/plugin-api-test-suite/ci/run-ci.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium, type Page } from 'playwright'; +import { TEMPORARY_PREFIX } from '../src/framework/temporary'; import type { CoverageReport, TestResult } from '../src/framework/types'; import { startStaticServer, type StaticServer } from './static-server.ts'; @@ -42,6 +43,9 @@ const APP_ERROR_RE = /^(Internal Error|Unexpected Error|Assertion Error|Uncaught Exception|Uncaught Rejection):/; const MOCKED = !!process.env['MOCK_BACKEND']; + +// Limit for the whole run; each test also has its own 15s limit in the runner. +const RUN_TIMEOUT_MS = 300000; const MOCK_BASE_URL = 'http://localhost:3000'; const apiUrl = MOCKED ? MOCK_BASE_URL @@ -52,12 +56,13 @@ const headlessBundlePath = resolve( '../../../dist/apps/plugin-api-test-suite/headless.js', ); -// Source the permissions from the same manifest the real plugin ships with, so -// the CI sandbox never drifts from what users actually grant. +// Source the permissions and scope from the same manifest the real plugin ships +// with, so the CI sandbox never drifts from what users actually grant. const manifestPath = resolve(here, '../public/manifest.json'); -const PERMISSIONS: string[] = ( - JSON.parse(readFileSync(manifestPath, 'utf-8')) as { permissions: string[] } -).permissions; +const MANIFEST = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + permissions: string[]; + scope?: string; +}; function cleanId(id: string): string { return id.replace('~u', ''); @@ -94,7 +99,70 @@ async function login() { if (!authToken) throw new Error('Login failed: no auth-token cookie returned'); - return { authToken, defaultProjectId: loginData['~:default-project-id'] }; + return { + authToken, + defaultProjectId: loginData['~:default-project-id'], + defaultTeamId: cleanId(loginData['~:default-team-id']), + }; +} + +// Calls a backend RPC with plain JSON in and out. Commands without a result +// answer with an empty body. +async function rpc( + authToken: string, + method: string, + params: Record, +): Promise { + const response = await fetch(`${apiUrl}/api/main/methods/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + cookie: authToken, + }, + body: JSON.stringify(params), + }); + if (!response.ok) { + throw new Error(`${method} failed with status ${response.status}`); + } + const body = await response.text(); + return (body ? JSON.parse(body) : undefined) as T; +} + +// Deletes the teams the tests created, with their projects and files. +async function deleteTemporaryTeams(authToken: string): Promise { + const teams = await rpc<{ id: string; name: string }[]>( + authToken, + 'get-teams', + {}, + ); + const temporary = teams.filter((team) => + team.name.startsWith(TEMPORARY_PREFIX), + ); + for (const team of temporary) { + await rpc(authToken, 'delete-team', { id: team.id }); + } + return temporary.length; +} + +// Deletes the projects the tests created (see `src/framework/temporary.ts`), +// with the files in them. +async function deleteTemporaryProjects( + authToken: string, + teamId: string, +): Promise { + const projects = await rpc< + { id: string; name: string; deletedAt?: string | null }[] + >(authToken, 'get-projects', { 'team-id': teamId }); + // Projects already in the trash are listed too; skip them. + const temporary = projects.filter( + (project) => + !project.deletedAt && project.name.startsWith(TEMPORARY_PREFIX), + ); + for (const project of temporary) { + await rpc(authToken, 'delete-project', { id: project.id }); + } + return temporary.length; } async function createFile( @@ -130,11 +198,10 @@ async function createFile( return (await response.json()) as FileRpc; } -function getFileUrl(file: FileRpc): string { - const projectId = cleanId(file['~:project-id']); +function getFileUrl(file: FileRpc, teamId: string): string { const fileId = cleanId(file['~:id']); const pageId = cleanId(file['~:data']['~:pages'][0]); - return `${apiUrl}/#/workspace/${projectId}/${fileId}?page-id=${pageId}`; + return `${apiUrl}/?screen=workspace&team-id=${teamId}&file-id=${fileId}&page-id=${pageId}`; } // --- Mocked mode setup ------------------------------------------------------- @@ -348,6 +415,7 @@ async function main() { let server: StaticServer | undefined; let fileUrl: string; let authToken: string | undefined; + let teamId: string | undefined; if (MOCKED) { server = await startE2eServer(); @@ -356,8 +424,9 @@ async function main() { } else { const session = await login(); authToken = session.authToken; + teamId = session.defaultTeamId; const file = await createFile(authToken, session.defaultProjectId); - fileUrl = getFileUrl(file); + fileUrl = getFileUrl(file, session.defaultTeamId); } const renderWasm = process.env['RENDER_WASM']; @@ -468,7 +537,7 @@ async function main() { ); await page.evaluate( - ({ code, permissions }) => { + ({ code, permissions, scope }) => { ( globalThis as unknown as { ɵloadPlugin: (m: unknown) => void } ).ɵloadPlugin({ @@ -478,23 +547,45 @@ async function main() { icon: '', description: '', permissions, + scope, }); }, - { code: injectedCode, permissions: PERMISSIONS }, + { + code: injectedCode, + permissions: MANIFEST.permissions, + scope: MANIFEST.scope, + }, ); - await Promise.race([ - done, - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timed out waiting for test results')), - 120000, + try { + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timed out waiting for test results')), + RUN_TIMEOUT_MS, + ), ), - ), - ]); + ]); + } finally { + await browser.close(); + await server?.close(); - await browser.close(); - await server?.close(); + // Runs after a timeout too, so an interrupted run leaves nothing behind. + if (authToken && teamId) { + try { + const projects = await deleteTemporaryProjects(authToken, teamId); + const teams = await deleteTemporaryTeams(authToken); + if (projects > 0 || teams > 0) { + console.log( + `\nDeleted ${projects} temporary project(s) and ${teams} team(s).`, + ); + } + } catch (err) { + console.warn(`\nCould not delete temporary data: ${String(err)}`); + } + } + } printReport(results, coverage, skipped); diff --git a/plugins/apps/plugin-api-test-suite/public/manifest.json b/plugins/apps/plugin-api-test-suite/public/manifest.json index a56ee76498..99c2578a1d 100644 --- a/plugins/apps/plugin-api-test-suite/public/manifest.json +++ b/plugins/apps/plugin-api-test-suite/public/manifest.json @@ -3,6 +3,7 @@ "description": "Launcher for a battery of Penpot Plugin API tests", "code": "plugin.js", "version": 2, + "scope": "global", "icon": "assets/icon.png", "permissions": [ "content:read", @@ -13,6 +14,10 @@ "comment:read", "comment:write", "allow:downloads", - "allow:localstorage" + "allow:localstorage", + "allow:global", + "manage:projects", + "manage:teams", + "manage:delete" ] } diff --git a/plugins/apps/plugin-api-test-suite/src/framework/runner.ts b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts index 9c20d38969..62ee3b471b 100644 --- a/plugins/apps/plugin-api-test-suite/src/framework/runner.ts +++ b/plugins/apps/plugin-api-test-suite/src/framework/runner.ts @@ -98,6 +98,28 @@ export interface RunOptions { export type ResultReporter = (result: TestResult) => void; +/** File and page that were active when the run started. */ +interface Home { + fileId: string | null; + teamId: string | null; + pageId: string | null; +} + +// Reopens the home file when a test navigated to another one. Shape, page and +// file references taken before navigating are stale afterwards, so callers +// look objects up again by id. Uses the *raw* globals so it isn't credited +// toward coverage. +async function returnToHomeFile(home: Home): Promise { + if (!home.fileId) return; + const workspace = penpotMgmt.workspace; + if (workspace.status === 'ready' && workspace.fileId === home.fileId) return; + await penpotMgmt.openFile(home.fileId, { teamId: home.teamId ?? undefined }); +} + +function homePage(home: Home) { + return penpot.currentFile?.pages.find((page) => page.id === home.pageId); +} + function withTimeout(promise: void | Promise, ms: number): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -152,6 +174,14 @@ export async function runTests( : focused; const recorder = createRecorder(penpot, apiSurface as ApiSurface); + const management = recorder.wrap(penpotMgmt, 'PenpotMgmt'); + + // Fixtures shared by the tests of this run (see `TestContext.fixture`). + const fixtures = new Map>(); + const fixture = (key: string, create: () => Promise): Promise => { + if (!fixtures.has(key)) fixtures.set(key, create()); + return fixtures.get(key) as Promise; + }; // Run every test with strict, deterministic API behavior. Set through the // recording proxy so the flags also count towards coverage: @@ -162,13 +192,17 @@ export async function runTests( recorder.proxy.flags.throwValidationErrors = true; recorder.proxy.flags.naturalChildOrdering = true; - // Remember the page that was active when the run started. Tests share global - // state (selection, the active page) with no per-test reset, so a test that - // changes the active page — or fails before restoring it — would silently make - // every later test run on the wrong page. After each test we clear the - // selection and restore this page, all through the *raw* penpot so the cleanup - // isn't credited toward coverage. - const homePage = penpot.currentPage; + // Remember the file and page that were active when the run started. Tests + // share global state (selection, the active page, the open file) with no + // per-test reset, so a test that changes them — or fails before restoring + // them — would silently make every later test run in the wrong place. After + // each test we reopen this file and page and clear the selection, all through + // the *raw* globals so the cleanup isn't credited toward coverage. + const home: Home = { + fileId: penpotMgmt.workspace.fileId, + teamId: penpotMgmt.workspace.teamId, + pageId: penpot.currentPage?.id ?? null, + }; const results: TestResult[] = []; @@ -190,7 +224,7 @@ export async function runTests( // Create/name/remove the scratch board through the *raw* penpot so this // harness bookkeeping isn't credited toward coverage. The test still gets a // recording-wrapped board, so its own access to it is counted. - let rawBoard: ReturnType | undefined; + let boardId: string | undefined; let result: TestResult; // Baseline the file's integrity errors before the test so we can attribute @@ -198,13 +232,21 @@ export async function runTests( const integrityBaseline = integritySignatures(); try { - rawBoard = penpot.createBoard(); + const rawBoard = penpot.createBoard(); rawBoard.name = SCRATCH_NAME; + boardId = rawBoard.id; const board = recorder.wrap(rawBoard, 'Board'); await withTimeout( - testCase.fn({ penpot: recorder.proxy, board }), + testCase.fn({ + penpot: recorder.proxy, + penpotMgmt: management, + board, + fixture, + }), TEST_TIMEOUT_MS, ); + // The integrity baseline belongs to the home file, so check it there. + await returnToHomeFile(home); // Postcondition: the test must not have broken referential integrity. if (integrityBaseline) assertNoNewIntegrityErrors(integrityBaseline); result = { @@ -225,22 +267,27 @@ export async function runTests( // `test.nocleanup` keeps the scratch board and shared state as the test // left them so the result can be inspected in the workspace. if (!testCase.noCleanup) { - try { - rawBoard?.remove(); - } catch { - // best-effort cleanup; never fail a test because teardown failed - } // Reset shared state so the next test starts clean. All best-effort: a // teardown failure must never turn into a test failure. + try { + await returnToHomeFile(home); + } catch { + /* ignore */ + } + try { + if (boardId) homePage(home)?.getShapeById(boardId)?.remove(); + } catch { + /* ignore */ + } try { penpot.selection = []; } catch { /* ignore */ } try { - const active = penpot.currentPage; - if (homePage && active && active.id !== homePage.id) { - await penpot.openPage(homePage); + const page = homePage(home); + if (page && penpot.currentPage?.id !== page.id) { + await penpot.openPage(page); } } catch { /* ignore */ diff --git a/plugins/apps/plugin-api-test-suite/src/framework/temporary.ts b/plugins/apps/plugin-api-test-suite/src/framework/temporary.ts new file mode 100644 index 0000000000..80b86300af --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/framework/temporary.ts @@ -0,0 +1,14 @@ +// Shared by the tests (in the sandbox) and the CI driver (in Node), so it must +// stay free of imports. + +/** + * Name prefix of every team and project the tests create. Tests delete the + * teams they create; the live CI driver deletes the teams and projects with + * this prefix after a run. Runs from the plugin UI leave the projects behind. + */ +export const TEMPORARY_PREFIX = '__api_test_tmp__'; + +/** Returns a unique name with {@link TEMPORARY_PREFIX} for a test project. */ +export function temporaryName(label: string): string { + return `${TEMPORARY_PREFIX} ${label} ${Date.now()}`; +} diff --git a/plugins/apps/plugin-api-test-suite/src/framework/types.ts b/plugins/apps/plugin-api-test-suite/src/framework/types.ts index 665597478b..d9ff30e1dc 100644 --- a/plugins/apps/plugin-api-test-suite/src/framework/types.ts +++ b/plugins/apps/plugin-api-test-suite/src/framework/types.ts @@ -1,15 +1,24 @@ -import type { Board, Penpot } from '@penpot/plugin-types'; +import type { Board, Penpot, PenpotMgmt } from '@penpot/plugin-types'; export type TestStatus = 'pending' | 'running' | 'pass' | 'fail'; /** - * The context handed to every test. Tests MUST use `ctx.penpot` (the recording - * proxy) rather than the global `penpot` so their API usage is counted towards - * coverage. A fresh scratch `board` is provided per test and removed afterwards. + * The context handed to every test. Tests MUST use `ctx.penpot` and + * `ctx.penpotMgmt` (the recording proxies) rather than the globals so their API + * usage is counted towards coverage. A fresh scratch `board` is provided per + * test and removed afterwards. */ export interface TestContext { penpot: Penpot; + /** Recording proxy over `penpotMgmt`, available because the suite runs with global scope. */ + penpotMgmt: PenpotMgmt; board: Board; + /** + * Returns the value `create` resolves to, creating it the first time `key` + * is used in the current run. Use it for expensive fixtures shared by + * several tests; a later run creates them again. + */ + fixture(key: string, create: () => Promise): Promise; } export type TestFn = (ctx: TestContext) => void | Promise; diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index b32168ec1b..1025e9d764 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -138,10 +138,12 @@ "contentsave", "filechange", "finish", + "logout", "pagechange", "selectionchange", "shapechange", - "themechange" + "themechange", + "workspacechange" ], "Export": ["scale", "skipChildren", "suffix", "type"], "File": [ @@ -365,6 +367,16 @@ "Path": ["commands", "d", "fills", "type"], "PathCommand": ["command", "params"], "Penpot": ["closePlugin", "off", "on", "ui", "utils"], + "PenpotMgmt": [ + "createTeam", + "getFile", + "listProjects", + "listTeams", + "off", + "on", + "openFile", + "workspace" + ], "PluginData": [ "getPluginData", "getPluginDataKeys", @@ -375,6 +387,31 @@ ], "Point": ["x", "y"], "PreviousScreen": ["type"], + "Project": [ + "createFile", + "duplicate", + "fileCount", + "id", + "isDefault", + "listFiles", + "moveTo", + "name", + "pinned", + "remove", + "teamId" + ], + "ProjectFile": [ + "duplicate", + "id", + "modifiedAt", + "moveTo", + "name", + "open", + "projectId", + "remove", + "shared", + "teamId" + ], "Push": ["direction", "duration", "easing", "type"], "Rectangle": ["fills", "type"], "RulerGuide": ["board", "orientation", "position", "remove"], @@ -480,6 +517,14 @@ "strokeWidthType" ], "SvgRaw": ["type"], + "Team": [ + "createProject", + "id", + "isDefault", + "listProjects", + "name", + "remove" + ], "Text": [ "align", "applyTypography", @@ -538,6 +583,7 @@ "addTheme", "getSetById", "getThemeById", + "isEditableTokens", "sets", "themes" ], @@ -636,7 +682,8 @@ "zoomIntoView", "zoomReset", "zoomToFitAll" - ] + ], + "WorkspaceContext": ["fileId", "fileName", "status", "teamId"] }, "graph": { "ActiveUser": { @@ -2080,6 +2127,12 @@ } }, "Context": { + "management": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, "version": { "decl": "Context", "kind": "get", @@ -2914,6 +2967,18 @@ } }, "EventsMap": { + "workspacechange": { + "decl": "EventsMap", + "kind": "get", + "type": "WorkspaceContext", + "array": false + }, + "logout": { + "decl": "EventsMap", + "kind": "get", + "type": null, + "array": false + }, "pagechange": { "decl": "EventsMap", "kind": "get", @@ -6677,6 +6742,56 @@ "array": false } }, + "PenpotMgmt": { + "workspace": { + "decl": "PenpotMgmt", + "kind": "get", + "type": "WorkspaceContext", + "array": false + }, + "listTeams": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "Team", + "array": true + }, + "createTeam": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "Team", + "array": false + }, + "listProjects": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "Project", + "array": true + }, + "getFile": { + "decl": "PenpotMgmt", + "kind": "method", + "type": "ProjectFile", + "array": false + }, + "openFile": { + "decl": "PenpotMgmt", + "kind": "method", + "type": null, + "array": false + }, + "on": { + "decl": "PenpotMgmt", + "kind": "method", + "type": null, + "array": false + }, + "off": { + "decl": "PenpotMgmt", + "kind": "method", + "type": null, + "array": false + } + }, "PluginData": { "getPluginData": { "decl": "PluginData", @@ -6737,6 +6852,136 @@ "array": false } }, + "Project": { + "id": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "teamId": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "isDefault": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "fileCount": { + "decl": "Project", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Project", + "kind": "getset", + "type": null, + "array": false + }, + "pinned": { + "decl": "Project", + "kind": "getset", + "type": null, + "array": false + }, + "listFiles": { + "decl": "Project", + "kind": "method", + "type": "ProjectFile", + "array": true + }, + "createFile": { + "decl": "Project", + "kind": "method", + "type": "ProjectFile", + "array": false + }, + "duplicate": { + "decl": "Project", + "kind": "method", + "type": "Project", + "array": false + }, + "moveTo": { + "decl": "Project", + "kind": "method", + "type": null, + "array": false + }, + "remove": { + "decl": "Project", + "kind": "method", + "type": null, + "array": false + } + }, + "ProjectFile": { + "id": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "teamId": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "projectId": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "modifiedAt": { + "decl": "ProjectFile", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "ProjectFile", + "kind": "getset", + "type": null, + "array": false + }, + "shared": { + "decl": "ProjectFile", + "kind": "getset", + "type": null, + "array": false + }, + "open": { + "decl": "ProjectFile", + "kind": "method", + "type": null, + "array": false + }, + "moveTo": { + "decl": "ProjectFile", + "kind": "method", + "type": null, + "array": false + }, + "duplicate": { + "decl": "ProjectFile", + "kind": "method", + "type": "ProjectFile", + "array": false + }, + "remove": { + "decl": "ProjectFile", + "kind": "method", + "type": null, + "array": false + } + }, "Push": { "type": { "decl": "Push", @@ -8361,6 +8606,44 @@ "array": true } }, + "Team": { + "id": { + "decl": "Team", + "kind": "get", + "type": null, + "array": false + }, + "isDefault": { + "decl": "Team", + "kind": "get", + "type": null, + "array": false + }, + "name": { + "decl": "Team", + "kind": "getset", + "type": null, + "array": false + }, + "listProjects": { + "decl": "Team", + "kind": "method", + "type": "Project", + "array": true + }, + "createProject": { + "decl": "Team", + "kind": "method", + "type": "Project", + "array": false + }, + "remove": { + "decl": "Team", + "kind": "method", + "type": null, + "array": false + } + }, "Text": { "type": { "decl": "Text", @@ -9274,6 +9557,12 @@ } }, "TokenCatalog": { + "isEditableTokens": { + "decl": "TokenCatalog", + "kind": "get", + "type": null, + "array": false + }, "themes": { "decl": "TokenCatalog", "kind": "get", @@ -11330,6 +11619,32 @@ "type": null, "array": false } + }, + "WorkspaceContext": { + "status": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + }, + "fileId": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + }, + "fileName": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + }, + "teamId": { + "decl": "WorkspaceContext", + "kind": "get", + "type": null, + "array": false + } } }, "unions": { diff --git a/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts new file mode 100644 index 0000000000..1c451f5ac1 --- /dev/null +++ b/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts @@ -0,0 +1,555 @@ +import type { Project, ProjectFile, Team } from '@penpot/plugin-types'; +import { expect, expectReject } from '../framework/expect'; +import { describe, test } from '../framework/registry'; +import { temporaryName } from '../framework/temporary'; +import type { TestContext } from '../framework/types'; +import { waitFor, waitForAsync } from './wait'; + +// Application management (`penpotMgmt`), available because the suite runs with +// global scope. These tests create projects and files and move between them, +// so they need the real backend. Every project they create has a temporary +// name, and the live CI driver deletes those projects afterwards. + +async function currentTeam(ctx: TestContext): Promise { + const teamId = ctx.penpotMgmt.workspace.teamId; + const team = (await ctx.penpotMgmt.listTeams()).find((t) => t.id === teamId); + if (!team) throw new Error('The current team is not listed'); + return team; +} + +// One temporary project per run holds the files that tests only read. +function temporaryProject(ctx: TestContext): Promise { + return ctx.fixture('management:project', async () => + (await currentTeam(ctx)).createProject({ name: temporaryName('files') }), + ); +} + +async function temporaryFile( + ctx: TestContext, + name: string, +): Promise { + return (await temporaryProject(ctx)).createFile({ name }); +} + +// Opens `file`, runs `fn` there and comes back to the file the test started in. +async function inFile( + ctx: TestContext, + file: ProjectFile, + fn: () => void | Promise, +): Promise { + const home = ctx.penpotMgmt.workspace; + await file.open(); + try { + await fn(); + } finally { + await ctx.penpotMgmt.openFile(home.fileId!, { + teamId: home.teamId ?? undefined, + }); + } +} + +async function listedFile( + project: Project, + fileId: string, +): Promise { + return (await project.listFiles()).find((f) => f.id === fileId); +} + +async function listedProject( + ctx: TestContext, + projectId: string, +): Promise { + return (await ctx.penpotMgmt.listProjects()).find((p) => p.id === projectId); +} + +interface BrandLibrary { + file: ProjectFile; + colorName: string; + typographyName: string; + componentName: string; +} + +// A shared library with one color, typography and component, built once per +// run in its own file. Tests connect it to the open file, and connected +// libraries are no longer listed as available, so each run needs a new one. +function sharedLibrary(ctx: TestContext): Promise { + return ctx.fixture('management:brand-library', async () => { + const file = await temporaryFile(ctx, 'Brand library'); + const library: BrandLibrary = { + file, + colorName: 'brand-primary', + typographyName: 'brand-heading', + componentName: 'brand-button', + }; + + await inFile(ctx, file, () => { + const local = ctx.penpot.library.local; + const color = local.createColor(); + color.name = library.colorName; + color.color = '#112233'; + + const typography = local.createTypography(); + typography.name = library.typographyName; + + const rect = ctx.penpot.createRectangle(); + const component = local.createComponent([rect]); + component.name = library.componentName; + }); + + file.shared = true; + const isListed = async () => + (await ctx.penpot.library.availableLibraries()).some( + (l) => l.id === file.id, + ); + await waitForAsync(isListed); + if (!(await isListed())) { + throw new Error('The shared library is not listed as available'); + } + return library; + }); +} + +describe('Management', () => { + test('workspace describes the open file', (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + expect(workspace.status).toBe('ready'); + expect(workspace.fileId).toBe(ctx.penpot.currentFile?.id); + expect(workspace.fileName).toBe(ctx.penpot.currentFile?.name); + expect(typeof workspace.teamId).toBe('string'); + }); + + test('openFile resolves at once for the open file', async (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + await ctx.penpotMgmt.openFile(workspace.fileId!, { + teamId: workspace.teamId ?? undefined, + }); + expect(ctx.penpotMgmt.workspace).toEqual(workspace); + }); + + test('management methods reject invalid identifiers', async (ctx) => { + const mgmt = ctx.penpotMgmt; + await expectReject(mgmt.openFile('not-a-uuid'), 'file UUID'); + await expectReject(mgmt.getFile('not-a-uuid'), 'file UUID'); + await expectReject( + mgmt.listProjects({ teamId: 'not-a-uuid' }), + 'team UUID', + ); + }); + + describe.skipIfMocked('Discovery', () => { + test('getFile loads the open file', async (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + const file = await ctx.penpotMgmt.getFile(workspace.fileId!); + + expect(file.id).toBe(workspace.fileId); + expect(file.name).toBe(workspace.fileName); + expect(file.teamId).toBe(workspace.teamId); + expect(file.shared).toBe(false); + expect(Number.isNaN(Date.parse(file.modifiedAt))).toBe(false); + + const parent = await listedProject(ctx, file.projectId); + expect(parent).toBeDefined(); + expect((await listedFile(parent!, file.id))?.name).toBe(file.name); + }); + + test('getFile loads a file that is not open', async (ctx) => { + const created = await temporaryFile(ctx, 'Loaded by id'); + const file = await ctx.penpotMgmt.getFile(created.id); + + expect(file.name).toBe('Loaded by id'); + expect(file.projectId).toBe(created.projectId); + expect(file.teamId).toBe(created.teamId); + }); + }); + + describe.skipIfMocked('Teams', () => { + test('listTeams includes the current team', async (ctx) => { + const team = await currentTeam(ctx); + expect(typeof team.name).toBe('string'); + expect(typeof team.isDefault).toBe('boolean'); + }); + + test('team and root list the same projects', async (ctx) => { + const team = await currentTeam(ctx); + const fromTeam = (await team.listProjects()).map((p) => p.id).sort(); + const fromRoot = (await ctx.penpotMgmt.listProjects()) + .map((p) => p.id) + .sort(); + + expect(fromTeam).toEqual(fromRoot); + expect( + (await ctx.penpotMgmt.listProjects()).some((p) => p.isDefault), + ).toBe(true); + }); + + test('teams do not expose deletion', async (ctx) => { + expect('remove' in (await currentTeam(ctx))).toBe(false); + }); + + test('assigning name renames the team', async (ctx) => { + const team = await currentTeam(ctx); + const original = team.name; + const renamed = `${original} (renamed)`; + + try { + team.name = renamed; + expect(team.name).toBe(renamed); + await waitForAsync( + async () => (await currentTeam(ctx)).name === renamed, + ); + expect((await currentTeam(ctx)).name).toBe(renamed); + } finally { + team.name = original; + await waitForAsync( + async () => (await currentTeam(ctx)).name === original, + ); + } + }); + }); + + describe.skipIfMocked('Projects', () => { + test('createProject and createFile return objects', async (ctx) => { + const home = ctx.penpotMgmt.workspace; + const team = await currentTeam(ctx); + const name = temporaryName('created'); + const created = await team.createProject({ name: ` ${name} ` }); + + expect(created.name).toBe(name); + expect(created.teamId).toBe(team.id); + expect(created.isDefault).toBe(false); + expect(created.pinned).toBe(false); + expect(created.fileCount).toBe(0); + + const file = await created.createFile({ name: ' Login ' }); + expect(file.name).toBe('Login'); + expect(file.projectId).toBe(created.id); + expect(file.teamId).toBe(team.id); + expect(file.shared).toBe(false); + expect(created.fileCount).toBe(1); + expect((await created.listFiles()).map((f) => f.id)).toEqual([file.id]); + // Creating does not navigate. + expect(ctx.penpotMgmt.workspace).toEqual(home); + }); + + test('assigning name and pinned updates the project', async (ctx) => { + const team = await currentTeam(ctx); + const created = await team.createProject({ + name: temporaryName('to rename'), + }); + const renamed = temporaryName('renamed'); + + created.name = renamed; + created.pinned = true; + expect(created.name).toBe(renamed); + expect(created.pinned).toBe(true); + + await waitForAsync(async () => { + const listed = await listedProject(ctx, created.id); + return listed?.name === renamed && listed.pinned; + }); + const listed = await listedProject(ctx, created.id); + expect(listed?.name).toBe(renamed); + expect(listed?.pinned).toBe(true); + }); + + test('duplicate copies a project with its files', async (ctx) => { + const team = await currentTeam(ctx); + const original = await team.createProject({ + name: temporaryName('original'), + }); + await original.createFile({ name: 'Copied' }); + const name = temporaryName('copy'); + + const copy = await original.duplicate({ name }); + expect(copy.id).not.toBe(original.id); + expect(copy.name).toBe(name); + expect((await copy.listFiles()).map((f) => f.name)).toEqual(['Copied']); + }); + + test('remove deletes a project', async (ctx) => { + const team = await currentTeam(ctx); + const created = await team.createProject({ + name: temporaryName('removed'), + }); + + await created.remove(); + expect(await listedProject(ctx, created.id)).toBeUndefined(); + }); + + test('moveTo moves a project to another team', async (ctx) => { + const team = await currentTeam(ctx); + const target = await ctx.penpotMgmt.createTeam({ + name: temporaryName('team'), + }); + + try { + const moved = await team.createProject({ + name: temporaryName('moved'), + }); + await moved.moveTo(target); + + expect(moved.teamId).toBe(target.id); + expect((await target.listProjects()).map((p) => p.id)).toContain( + moved.id, + ); + expect(await listedProject(ctx, moved.id)).toBeUndefined(); + } finally { + await target.remove(); + } + }); + }); + + describe.skipIfMocked('Files', () => { + test('assigning name renames a file', async (ctx) => { + const file = await temporaryFile(ctx, 'To rename'); + const parent = await temporaryProject(ctx); + + file.name = ' Renamed '; + expect(file.name).toBe('Renamed'); + await waitForAsync( + async () => (await listedFile(parent, file.id))?.name === 'Renamed', + ); + expect((await listedFile(parent, file.id))?.name).toBe('Renamed'); + }); + + test('duplicate copies a file into its project', async (ctx) => { + const file = await temporaryFile(ctx, 'Original'); + const copy = await file.duplicate({ name: 'Duplicated' }); + + expect(copy.id).not.toBe(file.id); + expect(copy.name).toBe('Duplicated'); + expect(copy.projectId).toBe(file.projectId); + expect( + (await listedFile(await temporaryProject(ctx), copy.id))?.name, + ).toBe('Duplicated'); + }); + + test('moveTo moves a file to another project', async (ctx) => { + const file = await temporaryFile(ctx, 'Moved'); + const team = await currentTeam(ctx); + const target = await team.createProject({ + name: temporaryName('target'), + }); + + await file.moveTo(target); + expect(file.projectId).toBe(target.id); + expect(await listedFile(target, file.id)).toBeDefined(); + expect( + await listedFile(await temporaryProject(ctx), file.id), + ).toBeUndefined(); + }); + + test('remove deletes a file', async (ctx) => { + const file = await temporaryFile(ctx, 'Removed'); + + await file.remove(); + expect( + await listedFile(await temporaryProject(ctx), file.id), + ).toBeUndefined(); + }); + + test('renaming the open file updates the workspace', async (ctx) => { + const workspace = ctx.penpotMgmt.workspace; + const file = await ctx.penpotMgmt.getFile(workspace.fileId!); + const original = file.name; + + try { + file.name = `${original} (renamed)`; + await waitFor( + () => ctx.penpotMgmt.workspace.fileName === `${original} (renamed)`, + ); + expect(ctx.penpotMgmt.workspace.fileName).toBe(`${original} (renamed)`); + } finally { + file.name = original; + } + }); + }); + + describe.skipIfMocked('Navigation', () => { + test('open switches to another file and back', async (ctx) => { + const home = ctx.penpotMgmt.workspace; + const other = await temporaryFile(ctx, 'Navigation'); + + await other.open(); + expect(ctx.penpotMgmt.workspace).toEqual({ + status: 'ready', + fileId: other.id, + fileName: 'Navigation', + teamId: other.teamId, + }); + expect(ctx.penpot.currentFile?.id).toBe(other.id); + expect(ctx.penpot.currentPage).not.toBeNull(); + + await ctx.penpotMgmt.openFile(home.fileId!, { + teamId: home.teamId ?? undefined, + }); + expect(ctx.penpotMgmt.workspace).toEqual(home); + expect(ctx.penpot.currentFile?.id).toBe(home.fileId); + }); + + test('file events fire when switching files', async (ctx) => { + const home = ctx.penpotMgmt.workspace; + const other = await temporaryFile(ctx, 'Events'); + + const workspaces: string[] = []; + const files: string[] = []; + const finished: string[] = []; + const workspaceListener = ctx.penpotMgmt.on('workspacechange', (w) => { + workspaces.push(`${w.status}:${w.fileId}`); + }); + const fileListener = ctx.penpot.on('filechange', (file) => { + files.push(file.id); + }); + const finishListener = ctx.penpot.on('finish', (fileId) => { + finished.push(fileId); + }); + + try { + await inFile(ctx, other, () => undefined); + await waitFor(() => files.includes(home.fileId!)); + } finally { + ctx.penpotMgmt.off(workspaceListener); + ctx.penpot.off(fileListener); + ctx.penpot.off(finishListener); + } + + expect(workspaces).toContain(`loading:${other.id}`); + expect(workspaces).toContain(`ready:${other.id}`); + expect(workspaces).toContain(`ready:${home.fileId}`); + expect(files).toContain(other.id); + expect(files).toContain(home.fileId); + expect(finished).toContain(home.fileId); + expect(finished).toContain(other.id); + }); + + test('off stops workspacechange notifications', async (ctx) => { + const other = await temporaryFile(ctx, 'Unsubscribed'); + let count = 0; + const listener = ctx.penpotMgmt.on('workspacechange', () => { + count += 1; + }); + ctx.penpotMgmt.off(listener); + + await inFile(ctx, other, () => undefined); + expect(count).toBe(0); + }); + + test('changes made in another file persist', async (ctx) => { + const other = await temporaryFile(ctx, 'Persistence'); + let shapeId = ''; + + await inFile(ctx, other, () => { + const rect = ctx.penpot.createRectangle(); + rect.name = 'remote-shape'; + shapeId = rect.id; + }); + + await inFile(ctx, other, () => { + const shape = ctx.penpot.currentPage?.getShapeById(shapeId); + expect(shape?.name).toBe('remote-shape'); + }); + }); + }); + + describe.skipIfMocked('Shared libraries', () => { + test('assigning shared publishes and unpublishes a library', async (ctx) => { + const file = await temporaryFile(ctx, 'Publishing'); + const isListed = async () => + (await ctx.penpot.library.availableLibraries()).some( + (library) => library.id === file.id, + ); + + file.shared = true; + expect(file.shared).toBe(true); + await waitForAsync(isListed); + expect(await isListed()).toBe(true); + expect( + (await listedFile(await temporaryProject(ctx), file.id))?.shared, + ).toBe(true); + + file.shared = false; + await waitForAsync(async () => !(await isListed())); + expect(await isListed()).toBe(false); + }); + + test('availableLibraries summarises a shared library', async (ctx) => { + const library = await sharedLibrary(ctx); + const summary = (await ctx.penpot.library.availableLibraries()).find( + (item) => item.id === library.file.id, + ); + + expect(summary).toBeDefined(); + expect(summary?.name).toBe('Brand library'); + expect(summary?.numColors).toBe(1); + expect(summary?.numTypographies).toBe(1); + expect(summary?.numComponents).toBe(1); + }); + + test('connectLibrary links a shared library to the file', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + + expect(connected.id).toBe(library.file.id); + expect(connected.name).toBe('Brand library'); + expect(connected.colors.map((c) => c.name)).toContain(library.colorName); + expect(connected.typographies.map((t) => t.name)).toContain( + library.typographyName, + ); + expect(connected.components.map((c) => c.name)).toContain( + library.componentName, + ); + expect( + ctx.penpot.library.connected.some((l) => l.id === library.file.id), + ).toBe(true); + }); + + test('connected library colors fill shapes by reference', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + const color = connected.colors.find((c) => c.name === library.colorName); + expect(color).toBeDefined(); + + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.fills = [color!.asFill()]; + + expect(rect.fills[0]?.fillColor).toBe('#112233'); + expect(rect.fills[0]?.fillColorRefId).toBe(color!.id); + expect(rect.fills[0]?.fillColorRefFile).toBe(library.file.id); + }); + + test('connected library components create linked instances', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + const component = connected.components.find( + (c) => c.name === library.componentName, + ); + expect(component).toBeDefined(); + + const instance = component!.instance(); + ctx.board.appendChild(instance); + + expect(instance.isComponentInstance()).toBe(true); + expect(instance.component()?.id).toBe(component!.id); + expect(instance.component()?.libraryId).toBe(library.file.id); + }); + + test('connected libraries reject plugin data writes', async (ctx) => { + const library = await sharedLibrary(ctx); + const connected = await ctx.penpot.library.connectLibrary( + library.file.id, + ); + + expect(() => connected.setPluginData('key', 'value')).toThrow(); + expect(() => + connected.setSharedPluginData('ns', 'key', 'value'), + ).toThrow(); + }); + }); +}); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts index 7c546a9e6a..3009ff8008 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/pages.test.ts @@ -52,10 +52,11 @@ describe('Pages', () => { const page = ctx.penpot.createPage(); await ctx.penpot.openPage(page); page.remove(); - // The switch to another page happens asynchronously; wait for it. + // The switch to another page happens asynchronously; wait for it. Until it + // lands the workspace is not ready, so `currentPage` can briefly be null. const start = Date.now(); let active = ctx.penpot.currentPage; - while (active && active.id === page.id && Date.now() - start < 2000) { + while ((!active || active.id === page.id) && Date.now() - start < 2000) { await new Promise((resolve) => setTimeout(resolve, 50)); active = ctx.penpot.currentPage; } diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait.ts index 36eecc2bb4..dbe60af3b4 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/wait.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait.ts @@ -20,3 +20,20 @@ export async function waitFor( await new Promise((resolve) => setTimeout(resolve, interval)); } } + +/** + * Like {@link waitFor}, for conditions that need an async check, such as + * listing from the backend after a change that saves in the background. + */ +export async function waitForAsync( + predicate: () => Promise, + { + timeout = 5000, + interval = 100, + }: { timeout?: number; interval?: number } = {}, +): Promise { + const start = Date.now(); + while (!(await predicate()) && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, interval)); + } +} diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 135defef1f..f1e1d68e53 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -786,42 +786,212 @@ export interface WorkspaceContext { readonly teamId: string | null; } -/** Metadata snapshot of an accessible, non-deleted project. */ -export interface ProjectSummary { +/** + * A team the user belongs to. Requires `manage:teams`. + * + * Values are a snapshot taken when the team was listed; changes made through + * this object update it. + * Property assignments run asynchronously and update values after success. + * Backend failures are logged to the console; assignments cannot be awaited. + */ +export interface Team { readonly id: string; - readonly teamId: string; - readonly name: string; - /** Whether this is the team's Drafts project. */ + /** Whether this is the user's personal team. */ readonly isDefault: boolean; - readonly fileCount: number; + /** + * The team name. Assigning renames the team; requires `manage:teams`. + * Trims the name and requires 1 to 250 characters. + */ + name: string; + /** + * Lists the team's projects. Requires `content:read`. + * + * @example + * ```ts + * const [team] = await penpotMgmt.listTeams(); + * const projects = await team.listProjects(); + * ``` + */ + listProjects(): Promise; + /** Lists projects still recoverable from Trash. Requires `content:read`. */ + listDeletedProjects(): Promise; + /** Lists files still recoverable from Trash. Requires `content:read`. */ + listDeletedFiles(): Promise; + /** + * Creates a project in the team. Requires `manage:projects`. + * + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation. + * + * @example + * ```ts + * const project = await team.createProject({ name: 'Design system' }); + * ``` + */ + createProject(options: { name: string }): Promise; } -/** Metadata snapshot of an accessible, non-deleted file. */ -export interface FileSummary { +/** + * A project in the dashboard. + * + * Values are a snapshot taken when the project was listed; changes made through + * this object update it. + * Property assignments run asynchronously and update values after success. + * Backend failures are logged to the console; assignments cannot be awaited. + */ +export interface Project { + readonly id: string; + readonly teamId: string; + /** Whether this is the team's Drafts project. */ + readonly isDefault: boolean; + /** Number of files when the project was listed; file operations do not update it. */ + readonly fileCount: number; + /** + * The project name. Assigning renames the project; requires `manage:projects`. + * Trims the name and requires 1 to 250 characters. + */ + name: string; + /** Whether the project is pinned in the sidebar. Assigning requires `manage:projects`. */ + pinned: boolean; + /** + * Lists the files in the project. Requires `content:read`. + * + * @example + * ```ts + * const files = await project.listFiles(); + * ``` + */ + listFiles(): Promise; + /** Lists the project's files still recoverable from Trash. Requires `content:read`. */ + listDeletedFiles(): Promise; + /** + * Restores the project through its files using the dashboard's Trash operation. + * Requires `manage:projects` and team edit access. Does not require `manage:delete`. + * Rejects if the project has no recoverable files, including empty projects. + */ + restore(): Promise; + /** + * Creates a file with an initial page in the project. Requires `content:write`. + * + * - Trims `name` and requires 1 to 250 characters. + * - Does not open the file. Use `ProjectFile.open` to edit it. + * + * @example + * ```ts + * const file = await project.createFile({ name: 'Login' }); + * await file.open(); + * ``` + */ + createFile(options: { name: string }): Promise; + /** + * Duplicates the project with all its files. Requires `manage:projects`. + * + * `name` defaults to the name the backend chooses. + */ + duplicate(options?: { name?: string }): Promise; + /** Moves the project to another team. Requires `manage:teams`. */ + moveTo(team: Team): Promise; + /** + * Moves the project and its files to Trash. Requires `manage:delete`. + * They can be restored during the retention period. Cannot permanently delete. + */ + remove(): Promise; +} + +/** + * A file in the dashboard, which may or may not be open. + * + * Values are a snapshot taken when the file was listed; changes made through + * this object update it. To read or edit its content, `open` it and use + * `penpot.currentFile`. + * Property assignments run asynchronously and update values after success. + * Backend failures are logged to the console; assignments cannot be awaited. + */ +export interface ProjectFile { readonly id: string; readonly teamId: string; readonly projectId: string; - readonly name: string; /** Last modification time as an ISO 8601 string in UTC. */ readonly modifiedAt: string; + /** + * The file name. Assigning renames the file; requires `content:write`. + * Trims the name and requires 1 to 250 characters. + */ + name: string; + /** + * Whether the file is shared as a library with its team. Assigning requires + * `library:write`. A shared file is listed by + * `penpot.library.availableLibraries()`; unsharing copies its assets into + * every file that uses it and disconnects them. + */ + shared: boolean; + /** + * Opens the file in this tab and resolves once its workspace and page are + * ready. Requires `content:read`. + */ + open(): Promise; + /** + * Restores the file using the dashboard's Trash operation, including its parent + * project if deleted. Requires `content:write` and team edit access. + * Rejects if the file no longer exists. Does not require `manage:delete`. + */ + restore(): Promise; + /** + * Moves the file to another project of the same team. Requires `content:write`. + * Rejects a destination that currently belongs to another team. + */ + moveTo(project: Project): Promise; + /** + * Duplicates the file in its project. Requires `content:write`. + * + * `name` defaults to the name the backend chooses. + */ + duplicate(options?: { name?: string }): Promise; + /** + * Moves the file to Trash. Requires `manage:delete`. + * It can be restored during the retention period. Cannot permanently delete. + */ + remove(): Promise; } /** * Application management API exposed as `penpotMgmt` to global plugins. * - * - Discovery and navigation require `content:read`; creation requires `content:write`. - * - Available in the dashboard and file workspaces. - * - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. + * - Lists teams, projects and files, and opens files, from the dashboard or a + * file workspace. Teams, projects and files are objects with their own + * properties and methods. + * - Listing and opening require `content:read`; teams require `manage:teams`. + * - File operations on `penpot` require a ready workspace. * - Reacquire file, page and shape objects after changing files. */ export interface PenpotMgmt { /** Current workspace availability and metadata; usable for file operations when status is `ready`. */ readonly workspace: WorkspaceContext; /** - * Lists accessible, non-deleted projects as metadata snapshots. + * Lists the teams of the user. Requires `manage:teams`. + * + * @example + * ```ts + * const teams = await penpotMgmt.listTeams(); + * ``` + */ + listTeams(): Promise; + /** + * Creates a team with the user as owner. Requires `manage:teams`. + * + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation. + * + * @example + * ```ts + * const team = await penpotMgmt.createTeam({ name: 'Marketing' }); + * ``` + */ + createTeam(options: { name: string }): Promise; + /** + * Lists the projects of a team. Requires `content:read`. * * - `teamId` defaults to the current team. - * - Queries the backend without opening a file or changing navigation. * - Rejects invalid identifiers and backend access errors. * * @example @@ -829,55 +999,27 @@ export interface PenpotMgmt { * const projects = await penpotMgmt.listProjects(); * ``` */ - listProjects(options?: { teamId?: string }): Promise; + listProjects(options?: { teamId?: string }): Promise; /** - * Lists accessible, non-deleted files in the specified project as metadata snapshots. - * - * - Requires an explicit `projectId`. - * - Queries the backend without opening a file or changing navigation. - * - Rejects invalid identifiers and backend access errors. + * Lists projects still recoverable from Trash. Requires `content:read`. + * `teamId` defaults to the current team. Use `Project.restore` to recover them. + */ + listDeletedProjects(options?: { teamId?: string }): Promise; + /** + * Lists files still recoverable from Trash. Requires `content:read`. + * `teamId` defaults to the current team. Use `ProjectFile.restore` to recover them. + */ + listDeletedFiles(options?: { teamId?: string }): Promise; + /** + * Loads a file by id, open or not. Requires `content:read`. * * @example * ```ts - * const files = await penpotMgmt.listFiles({ projectId: project.id }); + * const file = await penpotMgmt.getFile(penpotMgmt.workspace.fileId!); + * file.name = 'Checkout'; * ``` */ - listFiles(options: { projectId: string }): Promise; - /** - * Creates a project and returns its metadata snapshot. Requires `content:write`. - * - * - `teamId` defaults to the current team. - * - Trims `name` and requires 1 to 250 characters. - * - Does not change navigation or open a file. - * - Rejects invalid input, backend access errors and quota errors. - * - * @example - * ```ts - * const project = await penpotMgmt.createProject({ name: 'Design system' }); - * ``` - */ - createProject(options: { - name: string; - teamId?: string; - }): Promise; - /** - * Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. - * - * - Requires an explicit `projectId`. - * - Trims `name` and requires 1 to 250 characters. - * - Does not open the file or change navigation. Use `openFile` to edit it. - * - Rejects invalid input, backend access errors and quota errors. - * - * @example - * ```ts - * const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); - * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); - * ``` - */ - createFile(options: { - name: string; - projectId: string; - }): Promise; + getFile(fileId: string): Promise; /** * Opens a file in this tab and resolves once its workspace and page are ready. * diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index 44f5645cc5..f122c46ffe 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -419,21 +419,29 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.workspace; }, + listTeams() { + checkPermission('manage:teams', false); + return plugin.context.management!.listTeams(); + }, + createTeam(options) { + checkPermission('manage:teams', false); + return plugin.context.management!.createTeam(options); + }, listProjects(options) { checkPermission('content:read', false); return plugin.context.management!.listProjects(options); }, - listFiles(options) { + listDeletedProjects(options) { checkPermission('content:read', false); - return plugin.context.management!.listFiles(options); + return plugin.context.management!.listDeletedProjects(options); }, - createProject(options) { - checkPermission('content:write', false); - return plugin.context.management!.createProject(options); + listDeletedFiles(options) { + checkPermission('content:read', false); + return plugin.context.management!.listDeletedFiles(options); }, - createFile(options) { - checkPermission('content:write', false); - return plugin.context.management!.createFile(options); + getFile(fileId) { + checkPermission('content:read', false); + return plugin.context.management!.getFile(fileId); }, openFile(fileId, options) { checkPermission('content:read', false); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index e53eddacc3..b90ec91f63 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -54,23 +54,39 @@ describe('Plugin api', () => { vi.clearAllMocks(); }); - describe('management listing', () => { + describe('management', () => { it.each(['none', 'loading', 'ready'])( - 'lists metadata with workspace status %s', + 'lists teams and projects and loads files with workspace status %s', async (status) => { + const teams = [{ id: 'team-id' }]; const projects = [{ id: 'project-id' }]; - const files = [{ id: 'file-id' }]; + const file = { id: 'file-id' }; const management = { workspace: { status }, + listTeams: vi.fn().mockResolvedValue(teams), + createTeam: vi.fn().mockResolvedValue(teams[0]), listProjects: vi.fn().mockResolvedValue(projects), - listFiles: vi.fn().mockResolvedValue(files), + listDeletedProjects: vi.fn().mockResolvedValue(projects), + listDeletedFiles: vi.fn().mockResolvedValue([file]), + getFile: vi.fn().mockResolvedValue(file), }; const { penpotMgmt } = createApi({ ...pluginManager, - manifest: { ...pluginManager.manifest, scope: 'global' }, + manifest: { + ...pluginManager.manifest, + permissions: ['content:read', 'manage:teams'], + scope: 'global', + }, context: { ...pluginManager.context, management }, } as any); + await expect(penpotMgmt.listTeams()).resolves.toEqual(teams); + await expect( + penpotMgmt.createTeam({ name: 'Marketing' }), + ).resolves.toEqual(teams[0]); + expect(management.createTeam).toHaveBeenCalledWith({ + name: 'Marketing', + }); await expect(penpotMgmt.listProjects()).resolves.toEqual(projects); expect(management.listProjects).toHaveBeenLastCalledWith(undefined); await penpotMgmt.listProjects({ teamId: 'team-id' }); @@ -78,18 +94,24 @@ describe('Plugin api', () => { teamId: 'team-id', }); await expect( - penpotMgmt.listFiles({ projectId: 'project-id' }), - ).resolves.toEqual(files); - expect(management.listFiles).toHaveBeenCalledWith({ - projectId: 'project-id', + penpotMgmt.listDeletedProjects({ teamId: 'team-id' }), + ).resolves.toEqual(projects); + expect(management.listDeletedProjects).toHaveBeenCalledWith({ + teamId: 'team-id', }); + await expect(penpotMgmt.listDeletedFiles()).resolves.toEqual([file]); + expect(management.listDeletedFiles).toHaveBeenCalledWith(undefined); + await expect(penpotMgmt.getFile('file-id')).resolves.toEqual(file); + expect(management.getFile).toHaveBeenCalledWith('file-id'); }, ); - it('requires content:read before querying metadata', () => { + it('requires content:read before listing projects or loading files', () => { const management = { listProjects: vi.fn(), - listFiles: vi.fn(), + listDeletedProjects: vi.fn(), + listDeletedFiles: vi.fn(), + getFile: vi.fn(), }; const { penpotMgmt } = createApi({ ...pluginManager, @@ -101,98 +123,66 @@ describe('Plugin api', () => { context: { ...pluginManager.context, management }, } as any); - expect(() => penpotMgmt.listProjects()).toThrow(); - expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow(); + expect(() => penpotMgmt.listProjects()).toThrow( + 'Permission content:read is not granted', + ); + expect(() => penpotMgmt.getFile('file-id')).toThrow( + 'Permission content:read is not granted', + ); + expect(() => penpotMgmt.listDeletedProjects()).toThrow( + 'Permission content:read is not granted', + ); + expect(() => penpotMgmt.listDeletedFiles()).toThrow( + 'Permission content:read is not granted', + ); expect(management.listProjects).not.toHaveBeenCalled(); - expect(management.listFiles).not.toHaveBeenCalled(); + expect(management.listDeletedProjects).not.toHaveBeenCalled(); + expect(management.listDeletedFiles).not.toHaveBeenCalled(); + expect(management.getFile).not.toHaveBeenCalled(); }); - }); - describe('management creation', () => { - it.each(['none', 'loading', 'ready'])( - 'creates projects and files with workspace status %s', - async (status) => { - const project = { id: 'project-id', name: 'Buttons' }; - const file = { id: 'file-id', name: 'Login' }; - const management = { - workspace: { status }, - createProject: vi.fn().mockResolvedValue(project), - createFile: vi.fn().mockResolvedValue(file), - }; - const { penpotMgmt } = createApi({ - ...pluginManager, - manifest: { - ...pluginManager.manifest, - permissions: ['content:write'], - scope: 'global', - }, - context: { ...pluginManager.context, management }, - } as any); + it('requires manage:teams before listing or creating teams', () => { + const management = { listTeams: vi.fn(), createTeam: vi.fn() }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: ['content:read'], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); - await expect( - penpotMgmt.createProject({ name: 'Buttons' }), - ).resolves.toEqual(project); - await expect( - penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }), - ).resolves.toEqual(project); - expect(management.createProject).toHaveBeenLastCalledWith({ - name: 'Buttons', - teamId: 'team-id', - }); - await expect( - penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), - ).resolves.toEqual(file); - expect(management.createFile).toHaveBeenCalledWith({ - name: 'Login', - projectId: 'project-id', - }); - }, - ); + expect(() => penpotMgmt.listTeams()).toThrow( + 'Permission manage:teams is not granted', + ); + expect(() => penpotMgmt.createTeam({ name: 'Marketing' })).toThrow( + 'Permission manage:teams is not granted', + ); + expect(management.listTeams).not.toHaveBeenCalled(); + expect(management.createTeam).not.toHaveBeenCalled(); + }); - it.each([{ permissions: [] }, { permissions: ['content:read'] }])( - 'requires content:write before creating with permissions $permissions', - ({ permissions }) => { - const management = { - createProject: vi.fn(), - createFile: vi.fn(), - }; - const { penpotMgmt } = createApi({ - ...pluginManager, - manifest: { ...pluginManager.manifest, permissions, scope: 'global' }, - context: { ...pluginManager.context, management }, - } as any); - - expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow( - 'Permission content:write is not granted', - ); - expect(() => - penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), - ).toThrow('Permission content:write is not granted'); - expect(management.createProject).not.toHaveBeenCalled(); - expect(management.createFile).not.toHaveBeenCalled(); - }, - ); - - it('preserves backend creation errors', async () => { + it('preserves backend errors', async () => { const error = new Error('Access denied'); const { penpotMgmt } = createApi({ ...pluginManager, - manifest: { ...pluginManager.manifest, scope: 'global' }, + manifest: { + ...pluginManager.manifest, + permissions: ['content:read', 'manage:teams'], + scope: 'global', + }, context: { ...pluginManager.context, management: { - createProject: vi.fn().mockRejectedValue(error), - createFile: vi.fn().mockRejectedValue(error), + listTeams: vi.fn().mockRejectedValue(error), + getFile: vi.fn().mockRejectedValue(error), }, }, } as any); - await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe( - error, - ); - await expect( - penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), - ).rejects.toBe(error); + await expect(penpotMgmt.listTeams()).rejects.toBe(error); + await expect(penpotMgmt.getFile('file-id')).rejects.toBe(error); }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index 357fe9f95f..b2cd2f81af 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -21,6 +21,9 @@ export const manifestSchema = z.object({ 'allow:downloads', 'allow:localstorage', 'allow:global', + 'manage:projects', + 'manage:teams', + 'manage:delete', 'clipboard:read', 'clipboard:write', ]), From e3b8b006dbc00c60a8944d80971e643c52ad2ebc Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Thu, 15 Oct 2026 10:05:44 +0200 Subject: [PATCH 090/325] :bug: Fix font family space in tokens (#11750) * :bug: Fix font family space in tokens * :bug: Resolve token merge conflicts Keep the font-family token fix and the newer background-plugin change together in the unreleased changelog and plugin API test suite. AI-assisted-by: gpt-6-luna --------- Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com> --- frontend/src/app/main/data/tokenscript.cljs | 71 +++++++++++++++++-- .../data/workspace/tokens/application.cljs | 2 +- .../data/workspace/tokens/propagation.cljs | 3 +- .../forms/controls/fonts_combobox.cljs | 2 +- .../tokens/management/forms/validators.cljs | 3 +- frontend/src/app/plugins/tokens.cljs | 9 +-- .../frontend_tests/plugins/tokens_test.cljs | 65 +++++++++++++++++ plugins/CHANGELOG.md | 1 + .../src/tests/tokens.test.ts | 58 +++++++++++++++ 9 files changed, 199 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index 22e07efc66..7f0cbee92e 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -8,9 +8,12 @@ processTokens TokenSymbol makeConfig]] + [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] - [app.main.data.workspace.tokens.errors :as wte])) + [app.common.types.token :as cto] + [app.main.data.workspace.tokens.errors :as wte] + [cuerdas.core :as str])) (l/set-level! :debug) @@ -78,6 +81,17 @@ (declare tokenscript-symbols->penpot-unit) +(defn font-family-symbols->penpot-unit + "Converts a resolved font-family value into a vector of family names. + Each family name is the string form of its whole entry, which is a list of + word symbols when the family reached tokenscript unquoted." + [^js v] + (when (some? v) + (let [entries (.-value v)] + (if (instance? js/Array entries) + (mapv str entries) + [(str v)])))) + (defn structured-token->penpot-map "Converts structured token (record or array) to penpot map format. Structured tokens are non-primitive token types like `typography` or `box-shadow`." @@ -85,9 +99,13 @@ (if (instance? js/Array (.-value token-symbol)) (mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] - (into {} (map (fn [[k v :as V]] - [(keyword k) (tokenscript-symbols->penpot-unit v)]) - entries))))) + (into {} (map (fn [[k v]] + (let [k (keyword k)] + ;; The font-family member of a composite is a family list + [k (if (= :font-family k) + (font-family-symbols->penpot-unit v) + (tokenscript-symbols->penpot-unit v))]))) + entries)))) (defn tokenscript-symbols->penpot-unit [^js v] (cond @@ -99,6 +117,15 @@ (percent-number-with-unit? v) (/ (.-value v) 100) :else (.-value v))) +(defn resolved-value->penpot-unit + "Converts the resolved value of a token of the given `type`. + Font families need the type: a list of symbols is a list of families at the + top level and the words of a single family name inside an entry." + [type ^js v] + (if (= :font-family type) + (font-family-symbols->penpot-unit v) + (tokenscript-symbols->penpot-unit v))) + ;; Processors ------------------------------------------------------------------ ;; The processor resolves tokens ;; resolved/error tokens get put back into a clojure structure directly during build time @@ -130,11 +157,45 @@ :onError on-error :getResult get-result})) +(defn- quote-font-family + "Quotes a font family name so tokenscript reads it as one string instead of + parsing its words (`Red` would become a color, `2P` a number and a word). + Entries with a token reference or already quoted are kept as they are." + [family] + (cond + (or (not (string? family)) + (seq (cto/find-token-value-references family)) + (re-matches #"^(['\"]).*\1$" family)) + family + + (not (str/includes? family "\"")) + (str "\"" family "\"") + + (not (str/includes? family "'")) + (str "'" family "'") + + :else + family)) + +(defn- quote-font-families + [value] + (if (sequential? value) + (mapv quote-font-family value) + value)) + +(defn- token-value->tokenscript-value + [type value] + (case type + :font-family (quote-font-families value) + :typography (cond-> value + (map? value) (d/update-when :font-family quote-font-families)) + value)) + (defn clj->token->tokenscript-token "Convert penpot token into a format that tokenscript can handle." [{:keys [type value]}] #js {"$type" (name type) - "$value" (clj->js value)}) + "$value" (clj->js (token-value->tokenscript-value type value))}) (defn clj-tokens->tokenscript-tokens "Convert penpot map of tokens into tokenscript map structure. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 13bfd42689..6b667e3631 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -736,7 +736,7 @@ resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) + (ts/resolved-value->penpot-unit (:type token) resolved-value) resolved-value) tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 04472fd046..aa43061336 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -218,7 +218,8 @@ (when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)] (->> (if (contains? cf/flags :tokenscript) (rx/of (-> (ts/resolve-tokens tokens-tree) - (d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit)))) + (d/update-vals #(update % :resolved-value + (partial ts/resolved-value->penpot-unit (:type %)))))) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 88e8346580..48d8586565 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -78,7 +78,7 @@ (fn [resolved-tokens] (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) + (ts/resolved-value->penpot-unit (:type token) resolved-value) resolved-value)] (if resolved-value (rx/of {:value resolved-value}) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index 4fb9be3e4f..428d2fee31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -46,7 +46,8 @@ (fn [resolved-tokens] (let [resolved-token (cond-> (get resolved-tokens (:name token)) (contains? cf/flags :tokenscript) - (update :resolved-value ts/tokenscript-symbols->penpot-unit))] + (update :resolved-value + (partial ts/resolved-value->penpot-unit (:type token))))] (cond (:resolved-value resolved-token) (rx/of resolved-token) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ae0f6bf171..e811c0db2e 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -252,13 +252,10 @@ (defn- font-families-resolved-value->js "Converts a resolved fontFamilies value (a tokenscript list symbol) into the - documented `string[]` shape rather than leaking the raw tokenscript structure." + documented `string[]` shape." [resolved-value] - (let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] - (cond - (nil? v) nil - (sequential? v) (clj->js v) - :else #js [v]))) + (some-> (ts/font-family-symbols->penpot-unit resolved-value) + (clj->js))) (defn- get-resolved-value [token tokens-tree] diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 56a3a2529d..60bf4845c6 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -525,6 +525,71 @@ (t/is (array? result)) (t/is (= ["Inter" "Arial"] (vec result))))) +(t/deftest font-family-token-resolved-value-keeps-multi-word-families + ;; An unquoted family whose name has several words resolves to a list of word + ;; symbols, so the family name is the string form of each entry. + (let [token (ctob/make-token + {:name "font.body" + :type :font-family + :value ["Hanken Grotesk" "IBM Plex Mono"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (array? result)) + (t/is (= ["Hanken Grotesk" "IBM Plex Mono"] (vec result))))) + +(t/deftest typography-token-resolved-value-keeps-multi-word-families + (let [token (ctob/make-token + {:name "type.body" + :type :typography + :value {:font-family ["Hanken Grotesk" "Arial"] + :font-size "16px"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (= ["Hanken Grotesk" "Arial"] (vec (aget entry "fontFamilies")))))) + +(t/deftest font-family-token-resolved-value-keeps-names-with-expression-words + ;; Words like `Red` or `Black` read as colors and `2P` as a number and a word + ;; when tokenscript parses the family name. + (let [families ["Red Hat Display" "Crimson Pro" "Archivo Black" + "Press Start 2P" "M PLUS 1p" "Crimson"] + token (ctob/make-token + {:name "font.display" + :type :font-family + :value families}) + result (get-resolved-value token {(:name token) token})] + (t/is (= families (vec result))))) + +(t/deftest font-family-token-resolved-value-mixes-references-and-names + (let [base (ctob/make-token + {:name "font.base" + :type :font-family + :value ["Red Hat Text"]}) + token (ctob/make-token + {:name "font.stack" + :type :font-family + :value ["{font.base}" "Rock 3D"]}) + result (get-resolved-value token {(:name base) base + (:name token) token})] + (t/is (= ["Red Hat Text" "Rock 3D"] (vec result))))) + +(t/deftest font-family-token-resolved-value-keeps-quoted-names + (let [token (ctob/make-token + {:name "font.quoted" + :type :font-family + :value ["\"Red Hat Mono\"" "'Black Ops One'"]}) + result (get-resolved-value token {(:name token) token})] + (t/is (= ["Red Hat Mono" "Black Ops One"] (vec result))))) + +(t/deftest typography-token-resolved-value-keeps-names-with-expression-words + (let [token (ctob/make-token + {:name "type.display" + :type :typography + :value {:font-family ["Red Hat Display" "Press Start 2P"] + :font-size "16px"}}) + result (get-resolved-value token {(:name token) token}) + entry (aget result 0)] + (t/is (= ["Red Hat Display" "Press Start 2P"] + (vec (aget entry "fontFamilies")))))) + (t/deftest token-theme-add-set-accepts-token-set-id (let [plugin-id "plugin-id" file-id (uuid/next) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 33623eea15..26c357a8b8 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -25,6 +25,7 @@ - **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001. - **plugins-runtime**: The plugin modal no longer shows two resize grips in Firefox. Firefox now shows only its native grip. Closes #11795. - **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405. +- **plugins-runtime**: `TokenFontFamilies.resolvedValue` and the `fontFamilies` member of `TokenTypographyValue` now keep a family whose name has several words (`Hanken Grotesk`) as one entry, instead of splitting it into an array of its words, and keep names with words the resolver would otherwise read as colors or numbers (`Red Hat Display`, `Press Start 2P`) unchanged. - **plugins-runtime**: Opening a plugin no longer drops running background plugins (`allowBackground`, such as the MCP plugin) from the runtime registry. They kept running, but their UI messages were no longer delivered to the plugin and they could no longer be unloaded, so MCP tasks timed out after another plugin was opened. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 095a86ac17..550c64f261 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -418,6 +418,31 @@ describe('Tokens', () => { expect(Object.keys(b.tokens)).toContain('paddingLeft'); }); + // The target is a text shape because the attribute set of a board excludes + // the font-family property, so the application is filtered out there and + // the assertions pass vacuously. + // The family comes from the bundled Google fonts catalog, available in every + // run. `Red` in its name reads as a color if the resolver parses the words. + test('applyToken binds a fontFamilies token to a text shape', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const font = ctx.penpot.fonts.findByName('Red Hat Display'); + if (!font) throw new Error('Red Hat Display font not found'); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: [font.fontFamily], + }); + + const t = ctx.penpot.createText('Hello Penpot'); + if (!t) throw new Error('createText returned null'); + ctx.board.appendChild(t); + + t.applyToken(token, ['fontFamilies']); + await waitFor(() => t.fontFamily === font.fontFamily); + expect(Object.keys(t.tokens)).toContain('fontFamilies'); + expect(t.fontFamily).toBe(font.fontFamily); + }); + test('applyToken accepts the uniform strokeWidth property', async (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({ @@ -730,6 +755,39 @@ describe('Token types', () => { expect(resolved as unknown as string[]).toContain('Arial'); }); + // The resolver parses a family whose name has several words as a list of + // word symbols, so each family name must survive as a single entry. A + // single-word family takes the simpler path the test above covers. + test('fontFamilies token resolvedValue keeps multi-word families whole', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: ['Hanken Grotesk'], + }); + // The stored value keeps the family whole, isolating resolution below. + expect(token.value).toEqual(['Hanken Grotesk']); + + const resolved = token.resolvedValue as unknown as string[]; + expect(Array.isArray(resolved)).toBe(true); + expect(resolved).toHaveLength(1); + expect(resolved[0]).toBe('Hanken Grotesk'); + }); + + // Words like `Red` or `Black` read as colors and `2P` as a number and a word + // when the resolver parses the family name. + test('fontFamilies token resolvedValue keeps names with expression words', (ctx) => { + const set = activeSet(ctx, unique('set')); + const families = ['Red Hat Display', 'Archivo Black', 'Press Start 2P']; + const token = set.addToken({ + type: 'fontFamilies', + name: unique('fontFamilies.'), + value: families, + }); + + expect(token.resolvedValue as unknown as string[]).toEqual(families); + }); + test('shadow token exposes its composite value', (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({ From 7383e086837c5d868de6e29f312f96924236f8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Thu, 15 Oct 2026 10:10:11 +0200 Subject: [PATCH 091/325] :sparkles: Focus the search field when opening the color tokens tab (#12345) The color tokens tab of the color picker did not focus its search field, so users had to click it before typing a token name. The token dropdown of numeric inputs already focuses its search on open. Closes #12344 AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs index e08eda1d10..7aaf3ab797 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -310,6 +310,7 @@ :variant "comfortable" :class (stl/css :search-input) :default-value filter-term + :auto-focus true :on-change on-filter-tokens}] (if (seq sorted-tokens) [:div {:class (stl/css :color-tokens-inputs)} From 1211212fd202e0331d7c5bd69cf3b1cd9abed87c Mon Sep 17 00:00:00 2001 From: moeenio Date: Wed, 14 Oct 2026 10:53:02 +0200 Subject: [PATCH 092/325] :globe_with_meridians: Add translations for: French Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/ --- frontend/translations/fr.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index becda2c0a6..491405476e 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-10-10 16:51+0000\n" -"Last-Translator: Surfoo \n" +"PO-Revision-Date: 2026-10-15 08:51+0000\n" +"Last-Translator: moeenio \n" "Language-Team: French \n" "Language: fr\n" @@ -9,7 +9,7 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n!=1);\n" -"X-Generator: Weblate 2026.10.1.dev0\n" +"X-Generator: Weblate 2026.11.dev0\n" #: src/app/main/ui/auth/register.cljs:227, src/app/main/ui/static.cljs:191, src/app/main/ui/viewer/login.cljs:119 msgid "auth.already-have-account" @@ -566,7 +566,7 @@ msgstr "Information sur la configuration de l'exportation dans Penpot." #: src/app/main/ui/exports/assets.cljs:178 msgid "dashboard.export-shapes.no-elements" -msgstr "Aucun élément ne comporte de paramètres d'exportation." +msgstr "Il n'y a pas de formes avec des paramètres d'exportation." #: src/app/main/ui/exports/assets.cljs:189 msgid "dashboard.export-shapes.title" From 754e9dd7f6182c59ab4edaece7a2bb31bc25c1ee Mon Sep 17 00:00:00 2001 From: imagoiq Date: Wed, 14 Oct 2026 11:08:26 +0200 Subject: [PATCH 093/325] :globe_with_meridians: Add translations for: French Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/ --- frontend/translations/fr.po | 60 ++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 491405476e..cb7755a4f9 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "PO-Revision-Date: 2026-10-15 08:51+0000\n" -"Last-Translator: moeenio \n" +"Last-Translator: imagoiq \n" "Language-Team: French \n" "Language: fr\n" @@ -566,7 +566,7 @@ msgstr "Information sur la configuration de l'exportation dans Penpot." #: src/app/main/ui/exports/assets.cljs:178 msgid "dashboard.export-shapes.no-elements" -msgstr "Il n'y a pas de formes avec des paramètres d'exportation." +msgstr "Aucun élément ne comporte de paramètres d'exportation." #: src/app/main/ui/exports/assets.cljs:189 msgid "dashboard.export-shapes.title" @@ -2396,7 +2396,7 @@ msgstr "Copier dans le Presse-papiers" #: src/app/main/ui/inspect/styles/style_box.cljs:67, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:183 msgid "inspect.tabs.styles.toggle-style" -msgstr "Afficher/Masquer le panneau %s" +msgstr "Afficher/cacher le panneau %s" #: src/app/main/ui/inspect/styles/style_box.cljs:26 msgid "inspect.tabs.styles.token-panel" @@ -2975,7 +2975,7 @@ msgstr "Cacher" #: src/app/main/ui/viewer/comments.cljs:115, src/app/main/ui/workspace/comments.cljs:76 msgid "labels.hide-resolved-comments" -msgstr "Masquer les commentaires résolus" +msgstr "Cacher les commentaires résolus" #: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:113, src/app/main/ui/settings/shortcuts.cljs:544, src/app/main/ui/workspace/tokens/sidebar.cljs:142 msgid "labels.import" @@ -4092,8 +4092,8 @@ msgstr "Quitter l'organisation %s ?" #: src/app/main/ui/delete_shared.cljs:54 msgid "modals.move-shared-confirm.accept" msgid_plural "modals.move-shared-confirm.accept" -msgstr[0] "Déplacement" -msgstr[1] "Déplacements" +msgstr[0] "Déplacer" +msgstr[1] "Déplacer" #: src/app/main/ui/delete_shared.cljs:49 msgid "modals.move-shared-confirm.message" @@ -4233,11 +4233,11 @@ msgstr "" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:121 msgid "nitrate.code-activation.footer-after" -msgstr " et contactez-nous :" +msgstr "Envoyez le fichier à" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:126 msgid "nitrate.code-activation.footer-before" -msgstr "Besoin d'un code ? Téléchargez votre " +msgstr "et nous vous enverrons votre code." #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:119 msgid "nitrate.code-activation.footer-download" @@ -4855,7 +4855,7 @@ msgstr "Mettre au premier plan" #: src/app/main/data/workspace/shortcuts.cljs:86 msgid "shortcuts.clear-undo" -msgstr "Rétablir" +msgstr "Effacer l'historique d'annulation" #: src/app/main/data/workspace/path/shortcuts.cljs:127, src/app/main/data/workspace/shortcuts.cljs:93 msgid "shortcuts.copy" @@ -5035,7 +5035,7 @@ msgstr "Répartir horizontalement" #: src/app/main/data/workspace/shortcuts.cljs:681 msgid "shortcuts.hide-ui" -msgstr "Afficher/masquer l'interface utilisateur" +msgstr "Afficher/cacher l'interface utilisateur" #: src/app/main/ui/settings/shortcuts.cljs:536 msgid "shortcuts.import-export" @@ -5314,11 +5314,11 @@ msgstr "Afficher les mesures" #: src/app/main/data/workspace/shortcuts.cljs:592 msgid "shortcuts.show-pixel-grid" -msgstr "Afficher/masquer la grille de pixels" +msgstr "Afficher/cacher la grille de pixels" #: src/app/main/data/workspace/shortcuts.cljs:630 msgid "shortcuts.show-shortcuts" -msgstr "Afficher/masquer les raccourcis" +msgstr "Afficher/cacher les raccourcis" #: src/app/main/data/workspace/path/shortcuts.cljs:119 msgid "shortcuts.snap-nodes" @@ -5415,7 +5415,7 @@ msgstr "Activer/désactiver la palette de couleurs" #: src/app/main/data/workspace/shortcuts.cljs:439 msgid "shortcuts.toggle-comments-visibility" -msgstr "Afficher/masquer les commentaires" +msgstr "Afficher/cacher les commentaires" #: src/app/main/data/workspace/shortcuts.cljs:488 msgid "shortcuts.toggle-focus-mode" @@ -5427,7 +5427,7 @@ msgstr "Activer/désactiver le plein écran" #: src/app/main/data/workspace/shortcuts.cljs:569 msgid "shortcuts.toggle-guides" -msgstr "Afficher/Masquer les repères" +msgstr "Afficher/cacher les repères" #: src/app/main/data/workspace/shortcuts.cljs:653 msgid "shortcuts.toggle-history" @@ -5455,7 +5455,7 @@ msgstr "Verrouiller les proportions" #: src/app/main/data/workspace/shortcuts.cljs:555 msgid "shortcuts.toggle-rulers" -msgstr "Afficher/Masquer les règles" +msgstr "Afficher/cacher les règles" #: src/app/main/data/workspace/shortcuts.cljs:621 msgid "shortcuts.toggle-snap-guides" @@ -5467,7 +5467,7 @@ msgstr "Aligner sur les repères de règles" #: src/app/main/data/workspace/shortcuts.cljs:671 msgid "shortcuts.toggle-textpalette" -msgstr "Afficher/masquer la palette de texte" +msgstr "Afficher/cacher la palette de texte" #: src/app/main/data/dashboard/shortcuts.cljs:20, src/app/main/data/viewer/shortcuts.cljs:26, src/app/main/data/workspace/shortcuts.cljs:822 msgid "shortcuts.toggle-theme" @@ -5475,7 +5475,7 @@ msgstr "Changer de thème" #: src/app/main/data/workspace/shortcuts.cljs:453 msgid "shortcuts.toggle-visibility" -msgstr "Afficher/Masquer" +msgstr "Afficher/cacher" #: src/app/main/data/viewer/shortcuts.cljs:55 msgid "shortcuts.toggle-zoom-style" @@ -6557,31 +6557,31 @@ msgstr "Rechercher et remplacer" #: src/app/main/ui/workspace/main_menu.cljs:465 msgid "workspace.header.menu.hide-artboard-names" -msgstr "Masquer le nom des plans de travail" +msgstr "Cacher le nom des plans de travail" #: src/app/main/ui/workspace/main_menu.cljs:426 msgid "workspace.header.menu.hide-comments" -msgstr "Masquer les commentaires" +msgstr "Cacher les commentaires" #: src/app/main/ui/workspace/main_menu.cljs:401 msgid "workspace.header.menu.hide-guides" -msgstr "Masquer les repères" +msgstr "Cacher les repères" #: src/app/main/ui/workspace/main_menu.cljs:440 msgid "workspace.header.menu.hide-palette" -msgstr "Masquer la palette de couleurs" +msgstr "Cacher la palette de couleurs" #: src/app/main/ui/workspace/main_menu.cljs:477 msgid "workspace.header.menu.hide-pixel-grid" -msgstr "Masquer la grille de pixels" +msgstr "Cacher la grille de pixels" #: src/app/main/ui/workspace/main_menu.cljs:387 msgid "workspace.header.menu.hide-rules" -msgstr "Masquer les règles" +msgstr "Cacher les règles" #: src/app/main/ui/workspace/main_menu.cljs:452 msgid "workspace.header.menu.hide-textpalette" -msgstr "Masquer la palette de polices" +msgstr "Cacher la palette de polices" #: src/app/main/ui/workspace/main_menu.cljs:415 msgid "workspace.header.menu.lock-guides" @@ -6823,7 +6823,7 @@ msgstr "Enregistrer le style de couleur" #: src/app/main/ui/workspace/colorpicker/libraries.cljs:338 msgid "workspace.libraries.colors.toggle-color-palette" -msgstr "Afficher/masquer la palette de couleurs" +msgstr "Afficher/cacher la palette de couleurs" #: src/app/main/ui/workspace/libraries.cljs:120, src/app/main/ui/workspace/libraries.cljs:149, src/app/main/ui/workspace/libraries.cljs:191 msgid "workspace.libraries.components" @@ -6994,7 +6994,7 @@ msgstr "Activer/Désactiver le flou" #: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:138 msgid "workspace.options.blur-options.toggle-more-options" -msgstr "Afficher/masquer plus d'options" +msgstr "Afficher/cacher plus d'options" #: src/app/main/ui/exports/assets.cljs:332 msgid "workspace.options.cancel-export" @@ -7391,7 +7391,7 @@ msgstr "Repères" #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:231 msgid "workspace.options.guides.toggle-guide" -msgstr "Afficher/Masquer le repère" +msgstr "Afficher/cacher le repère" #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:583, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:602 msgid "workspace.options.height" @@ -7987,7 +7987,7 @@ msgstr "Solide" #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:384 msgid "workspace.options.stroke.toggle-stroke" -msgstr "Afficher/masquer le contour" +msgstr "Afficher/cacher le contour" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:135 msgid "workspace.options.text-options.align-bottom" @@ -8439,11 +8439,11 @@ msgstr "Groupe" #: src/app/main/ui/workspace/context_menu.cljs:571, src/app/main/ui/workspace/sidebar/layer_item.cljs:190 msgid "workspace.shape.menu.hide" -msgstr "Masquer" +msgstr "Cacher" #: src/app/main/ui/workspace/context_menu.cljs:853, src/app/main/ui/workspace/main_menu.cljs:489 msgid "workspace.shape.menu.hide-ui" -msgstr "Afficher/Masquer l'interface" +msgstr "Afficher/cacher l'interface" #: src/app/main/ui/workspace/context_menu.cljs:539, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:95 msgid "workspace.shape.menu.intersection" From c92b5f25da5bf4a63a537bd15253bf3db281bb74 Mon Sep 17 00:00:00 2001 From: Philippe Accorsi Date: Wed, 14 Oct 2026 10:03:01 +0200 Subject: [PATCH 094/325] :globe_with_meridians: Add translations for: French Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/ --- frontend/translations/fr.po | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index cb7755a4f9..a3770cdb7a 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,7 +1,8 @@ msgid "" msgstr "" "PO-Revision-Date: 2026-10-15 08:51+0000\n" -"Last-Translator: imagoiq \n" +"Last-Translator: Philippe Accorsi " +"\n" "Language-Team: French \n" "Language: fr\n" @@ -5487,7 +5488,7 @@ msgstr "Activer/désactiver le soulignement" #: src/app/main/data/workspace/grid_layout/shortcuts.cljs:43, src/app/main/data/workspace/path/shortcuts.cljs:194, src/app/main/data/workspace/shortcuts.cljs:72 msgid "shortcuts.undo" -msgstr "Annuler" +msgstr "Annuler la dernière action" #: src/app/main/data/workspace/shortcuts.cljs:209 msgid "shortcuts.ungroup" From 57fa280209c63e02dc1ca06e7311d877780f2f49 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 19:18:04 +0200 Subject: [PATCH 095/325] :bug: Validate and decode a job result with its declared schema (#12350) * :bug: Decode job results with the schema of the job-def A job result is stored as plain JSON, so a uuid comes back as the text it is on disk and a uuid used as a map key comes back as a keyword. The import resolution is a map keyed by file id, and the wizard lost that identity: choosing a library in the link step created no link at all, with no error and with the modal closing as if it had worked. A job-def can now declare a result schema and its precompiled decoder, the pair the params already have, and the substrate applies them when the row is read. A def that declares neither answers with the JSON the row holds, as before; a row whose job-def is gone is read untouched instead of raising. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Type the import result so its resolution keeps uuid keys The resolution of a version 3 import is a map keyed by the new file id, and the result column is plain JSON: without a schema of its own every key came back as a keyword while the :id of the entry came back as text, so the dashboard wizard could not match the file it was linking and created no library link at all. Declare the shape of the resolution (files, done and pending entries and their candidates) and give the job-def its result schema and decoder. The check before the result is stored keeps validating the same schema. Closes #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Take the imported file id from the entry, not the map key The wizard looked a file up in the selection with the key of the resolution map while it had indexed the selection by the :id of the entry. The key is keywordized on its way through the job result, so the lookup missed and the confirm button created no library link at all, with no error and with the modal closing as if it had worked. Take the id from the entry, the source the rest of the wizard already uses, and move the decision to a pure function covered by tests of both shapes of the resolution. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Test that an import result keeps its ids as uuids The regression this branch fixes is only visible through the read path of the substrate: the resolution of an import is a map keyed by the new file id, and a test that works with the value in memory never sees the keys coming back as keywords from the plain JSON column. Import a package whose library needs a replacement chosen by the user, read the result with `jobs/get-job` as the client does, and check the keys, the file ids and the ids of the candidates. Without the result decoder of the import job-def the test answers with text ids. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :books: Document the result schema of a job-def The memories and the docstring of the substrate said a job result has no schema of its own and comes back as the JSON the row holds. A job-def can now declare its result schema and decoder, and the substrate applies them when the row is read. Keep the rule next to it: a result map keyed by uuid needs its schema declared, or every key comes back as a keyword, and the link-later step of the import wizard takes the file id from the entry and never from the key of the resolution map. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :books: Fix the documented contract of a job result The docstring of get-job said a result has no schema of its own and comes back as the JSON the row holds, which is what this branch changes: the result is decoded with the schema its job-def declares, and a result that does not conform never reaches the column because the write validates it. Say the same in the management command that stores the result of an external worker, the other writer of the column. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Check the result of a job against its schema before storing it The result of a job crosses a plain JSON column, so a uuid comes back as text and a uuid used as a map key as a keyword. The job-def now declares the schema of its result and the substrate compiles the decoder from it, which is what the read path uses to get the types back. The same schema is the contract of the write path: complete refuses a result that does not conform, with the malli explanation, so nothing invalid reaches the column, and the answer is the same for the runner and for the external worker that completes a job through the management RPC. A job that is no longer completable is skipped without checking, and the read path decodes only: a row written by another version stays readable. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Check the result of an import inside its transaction The check ran after the transaction of the import had committed, so a result the schema does not accept failed the job with the files already created in the destination project: a half imported project and an error. The core decides whether the whole import is kept or rolled back, so the check belongs inside its transaction, which is what turns a contract violation into a rollback. The substrate checks the result again when the job is completed, which is the only net for a job that does not validate its own result. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :bug: Cover the edges of an import result read by the client The shape of a result with no libraries to resolve, and the one of a version 1 import, is the most common in production and neither was covered by the tests that read a result the way the client does. Cover both through jobs/get-job, and the case of a registry that no longer knows the kind of the row: it answers with the JSON of the column, keywordized keys and uuid as text, instead of raising. Say in the two tests that depend on the decoder what implementation without it makes them fail. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :books: Fix the prose and the fixtures of the result contract The result schema of a job-def is what the read decodes with and what the write checks: say that in the memories, with the rule that any uuid of a result needs it, whether a value or a map key. The motive of the wizard helper was a fact this branch makes false: what a key of the resolution map holds depends on the result schema of the job-def that wrote it, so the rule is to read the id from the entry. Same correction in its memory and in the test that pins the shape. The candidates of a pending entry match by slugified name, not by name, and every test row now states the kind of its job, because the name is what resolves its job-def when the result is read or checked. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :recycle: Keep the wizard link helper private and cover its shape The helper that decides which links the wizard creates belongs to its namespace, as its neighbours do: reach it from the test with @#', the pattern the frontend tests already use for a private helper. Cover the shapes it had no test for: a file with two libraries pending resolution, a file whose libraries were auto-linked, and a selection where only one of two entries was resolved. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh * :recycle: Share one job-row helper across the job tests Three test namespaces had their own copy of the same insert, differing only in the kind of the job and in whether the row holds the resource it consumes. Move it to the test helpers as create-job*, with the kind of the job as its first argument: it is what resolves the job-def of the row when its result is read or checked, so every test states it. The bang of the name goes away with it: the design rules keep it for the mutating API of app.db, and this is a fixture of the tests. The three namespaces lose the requires that only their removed copy used. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh --------- Signed-off-by: Andrey Antukh --- .serena/memories/backend/core.md | 8 +- .serena/memories/backend/subtleties.md | 2 +- .../frontend/dashboard-viewer-subtleties.md | 2 +- backend/src/app/jobs.clj | 107 ++++++++-- backend/src/app/rpc/commands/jobs.clj | 14 +- backend/src/app/rpc/management/jobs.clj | 12 +- backend/src/app/tasks/import_binfile.clj | 77 +++++-- .../test/backend_tests/binfile_jobs_test.clj | 200 +++++++++++++++--- backend/test/backend_tests/helpers.clj | 24 +++ backend/test/backend_tests/jobs_test.clj | 129 ++++++++++- .../tasks/export_binfile_test.clj | 31 +-- .../tasks/import_binfile_test.clj | 74 ++++--- .../src/app/main/ui/dashboard/import.cljs | 41 ++-- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/dashboard_import_linking_test.cljs | 128 +++++++++++ 15 files changed, 698 insertions(+), 153 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index ef916f93a4..e884cda4e4 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -55,7 +55,7 @@ For deeper details on transaction semantics, advisory locks, Transit vs JSON hel ## Background tasks (unified jobs) -Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init). The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref). +Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` in its namespace + a malli params schema + an `ig/init-key` that returns the job-def map `{::jobs/name, ::jobs/schema, ::jobs/handler, ::jobs/decoder, ::jobs/validator}` (decoder/validator precompiled at init), optionally with `::jobs/result-schema` when the result carries types plain JSON does not keep (a uuid, whether a value or a map key): the substrate compiles the decoder from that schema on registry init and checks a result against the schema before storing it. The registry is the `::jobs/defs` wiring in `app.main`, which also populates a module-level registry used by `jobs/submit` as fallback — job-def components cannot ig/ref `::jobs/defs` (wiring cycle). Submit from RPC code passes its RPC cfg (it carries `::jobs/defs` via ig/ref). - `::jobs/handler` is `(fn [context params] ...)`. It is NOT given the runner cfg: a job-def closes over its own dependencies, and handing it the runner cfg would let a handler reach the pool or any other component. A wrapper passes the context to its implementation only when the implementation takes it; most do not, and their implementation stays `[cfg params]`. - The context is built by `jobs/make-context` and has exactly five keys: `id`, `name`, `label`, `resource-id`, `profile-id`, behind a closed schema. `profile-id` is the owner of a user-facing job and nil for an internal one, and it is there so a handler can revalidate permissions and check who owns its resource without a second query. `queue` is absent on purpose (a routing detail of the dispatcher, not a property of the job) and so are `retry-num`/`max-retries`/`attempt` (retry policy belongs to the runner, and an attempt number cannot be durable because the `noop` strategy re-runs without incrementing the counter). @@ -64,14 +64,14 @@ Every background job is a job-def: a plain `(defn execute-X [cfg params] ...)` i - A job-def may carry optional metadata keys read from the registry by its consumers, never by the substrate: `::jobs/family` (e.g. `:export`) groups the jobs of a family for quotes, `::jobs/resource-role` (`:input` or `:output`) says whether the job's `resource_id` is what it consumes or what it produces, and `::jobs/queue-name` names the queue the creation command routes the job to (`submit-job` falls back to `:binfile` when the def does not set it). - Job resources (`app.jobs.storage`) are the objects a job owns through `job.resource_id`: they live in the `job-resource` bucket, are written with the profile of the job as owner (only that profile can read them back from `/assets/by-id`) and are never deduplicated. They carry no expiry of their own, because `expired-at` is stored as `deleted_at` and an object with it is unreadable from birth: the retention of the artifact is the retention of its job row. The writer marks the object as touched right away, so an artifact that no row references (a crash between the upload and the completion) is reclaimed by `storage-gc-touched`, and a row that does reference it freezes it until the jobs GC deletes the row and touches the object. - The binfile jobs (`:export-binfile` and `:import-binfile`) run the core of `bf.v1`/`bf.v3` through `app.binfile.jobs`, which turns the `:progress` taps of the core into `progress` events of the job with `jobs/heartbeat` (the `modified_at` touch at most once per second, progress events at up to ~200ms). Each event is a self-contained milestone: a `:stage` keyword of one vocabulary shared by the two formats (the same work has a different name in v1 and v3) and an optional `:counters` map, with the counter of the activity named after its own stage and the outer scopes in course as context, which the tap carries itself (the reader puts `::outer-counters` in every tap inside them — today only `:files`, extendable to `:teams`/`:projects` for multi-scope exports — so the adapter remembers nothing between taps and a tap that happens outside any scope has no context); the ids and names the taps carry never reach the event. Every tap is reported, so a tap with no units to count still keeps the job alive, and a milestone the throttle drops loses nothing because the next one carries the same state. The adapter checks that the job is still active before the run, and the beat raises an `:interrupt` right there on the run thread when the job is no longer active, so the work stops at its next event point inside the transaction of its handler, which rolls back. A run whose last tap predates the interruption is discarded by the check once it returns. The adapter passes the job id from the handler context. Their job-def components carry pool, storage, metrics and msgbus, because a job with a `profile_id` cannot store an event without a msgbus on its cfg. Both readers answer with the same shape (`:file-ids` and, in version 3, the resolution of the libraries), because the version 1 reader returns a bare set of file ids. -- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it, because the job-def has no result schema of its own. +- Both binfile handlers revalidate their permission at run time, not only when the job is created, because it can be revoked while the job waits: the export checks the read permission of every file before reading anything and stores the package in `job-resource` as the resource of the job (answering with the completion envelope), and the import checks the edition permission of the destination and runs the core inside a transaction, which is what makes a cancelled import roll back instead of leaving half of a file. The import releases its package (marks it as deleted and drops the reference from the job row in the same transaction) and deletes its temporary copy once the job is terminal, whatever the outcome. Both handlers check their own result against a schema before returning it (the import job-def declares that same schema as its result schema, which is what types the resolution when the row is read). - The domain cfg of a binfile request lives in `app.binfile.common` (`export-cfg`, `import-cfg`), so the legacy RPC and the jobs freeze the same ids, resolve the same team features and apply the same anti-zip-bomb limits. `resolve-export-type` is the translation the legacy RPC needs for its deprecated boolean flags; the creation command asks the caller for the type instead of guessing a default. - Two retention knobs, easy to confuse: `:jobs-retention` sweeps terminal internal rows (the ones without a profile), while `:jobs-user-ttl` is the default expiry a user-facing job gets when it is created; the jobs GC deletes the row once `expires_at` passes and touches its resource. The heavy work of a user job runs on its own `:binfile` queue (`:worker-binfile-parallelism`, default 1) so it does not block the default runner. - The quote of a family of jobs (`::export-jobs-per-profile`, `::import-jobs-per-profile`) counts the jobs of the profile that are still on their way (`new`/`scheduled`/`running`/`retry`) whose name the registry gives for that family, so a new job of the family is covered without touching the quote. - A user-facing job is created through `app.rpc.commands.jobs`. One command per job type: `create-export-binfile-job` and `create-import-binfile-job` name the job they create and their body carries only `:params`, whose schema the command imports from the job namespace (`::sm/params`), so the RPC validates the business params up front, before the command runs; the job-def is still the place that owns them and validates them again at run time. The commands check the permission and the quote of the family before the expensive part, submit with `max-retries 0`, an expiry from `:jobs-user-ttl` and the queue the job-def routes to through `::jobs/queue-name` (`:binfile` when the def says nothing; read by the command, never by the substrate), and answer with a summary of the row. The import command has its own creation schema (`import-binfile/schema:create-params`, version optional) because the request is not the stored params: the command fills the version (from the caller or the package header) and the manifest metadata kept for audit. It assembles the upload, reads the manifest of a version 3 package for the audit (never its content) and stores the package in `job-resource` as the resource of the job, releasing it when the job never comes to own it. -- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is the plain JSON the row holds, so a uuid comes back as the text it is on disk (a result has no schema of its own like the params do). The event log still has no read surface: a client that follows a job takes its progress from the websocket. +- `get-job` is the read surface a profile has over its own jobs: it answers with the same summary the creation commands return plus the `result` of a finished job or its public error (through `decode-job-error`, so the keywords come back), and a job of another profile is not distinguishable from one that does not exist: both answer `not-found`. `decode-row` decodes `params`, `result` and `error`, and the result is then decoded with the decoder the substrate compiled from the result schema the job-def the row names declares (`decode-result`; a def without one, or a row whose job-def is gone, keeps the plain JSON the row holds: a uuid as the text it is on disk, a uuid used as a map key as a keyword). The event log still has no read surface: a client that follows a job takes its progress from the websocket. - `heartbeat` uses named options `[cfg & {:keys [job-id progress] :as options}]` and returns the number of durable writes, so 0 means there was nothing to write: no job context, or the throttle did not allow it. A write that finds the job no longer active (terminal or gone) raises an `:interrupt` (`code :job-interrupted`, carrying the job id and the status it found) through the shared `jobs/check-active` instead of counting zero, so a task in course stops at its next beat; the interrupt is internal and never reaches HTTP (the management progress report answers `:skip` for it). The job id falls back to `::jobs/job-id` on the cfg and then to the runner-bound `*job-id*`. The `modified_at` touch is throttled to ~1s and the progress event to ~200ms, so the check fires at most once per second with no extra query (the read of the row happens only when a write already found nothing). A progress report is a milestone (`schema:progress`): a required `:stage` keyword plus an optional `:counters` map of `scope -> {:current, :total?}`, and no other key. Counters are non-negative, a `total` is positive and never lower than its `current`, and stage and counter keys are names of at most 64 characters. A milestone without counters is valid (a point of the run with no units to count), the stage vocabulary belongs to the worker and not to the substrate, and a client must degrade for a stage it does not know. Read a stored payload back with `jobs/decode-progress`: the database holds the keywords as strings. The 1s beat (down from 60s, so a cancelled heavy job is seen as gone at its next beat) costs one `modified_at` touch per beating job per second: cheap while the only heavy beater runs alone on its queue, revisit if concurrent beating jobs ever grow. -- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. +- `job.error` conforms to `app.jobs/schema:job-error` (`type` and `code` keywords, `hint` text, extra details allowed). Read it back with `jobs/decode-job-error`; a plain `db/decode-json-pgobject` leaves the keywords as strings. `complete` takes named options and an optional `resource-id`, which is only ever set, never replaced: it reads the row locked inside its own transaction and applies the rules in a fixed order, skipping a job that is no longer completable before refusing one for its resource, so the answer is a function of the row and not of the order two callers arrive in. It checks the result against the schema the job-def of the row declares before it writes (a violation raises `:invalid-result` and nothing is stored), and only for a transition that is going to write: a terminal job answers `:skip` without validating. `complete` is the single door of a result, so the check covers the runner and the external worker that completes a job through the management RPC (`::complete-job`), which stores a result the substrate would otherwise take blind. For worker dispatch, cron, retry semantics (`ex/raise :type ::wrk/retry` with `:delay`/`:strategy`), deduplication, job events, and queue internals: `mem:backend/subtleties`. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 18af2954aa..c95dd5d174 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -29,7 +29,7 @@ - Whether a call is nested is decided by the CONNECTION, not by an ambient flag: `db/transact!` binds `*after-commit-conn*` around the body, and a transactable that is not identical to that connection is an independent unit of work that owns its own after-commit context. A `tx-run!` that passes a pool while a transaction is open therefore commits on its own and drains its own callbacks, instead of leaving them in a context that may roll back. - `after-commit` callbacks run on commit only. A transaction marked `::db/rollback` (used by `srepl` and `app.tasks/offload-file-data`) commits nothing, so it runs none of them. - `db/run!` opens/reuses one connection but does not create a transaction. -- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`). +- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Job params (`job.params`) are plain JSON, decoded with the job-def decoder (`app.jobs/decode-params`); only the legacy `task` props were Transit (`decode-transit-pgobject`). Job results (`job.result`) are plain JSON too: they are read with the result decoder of the job-def (`app.jobs/decode-result`), and a result map keyed by uuid must declare its schema (`[:map-of ::sm/uuid ...]`, the import resolution does) or every key comes back as a keyword and every uuid as the text it is on disk. - Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash. ## Workers and cron diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index abd03e4f03..9c753d8875 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -5,7 +5,7 @@ - Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id. - Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly. - Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. -- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The legacy SSE import stays in the backend for other clients. +- The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its rows paint the milestone of the job (the stage and its counter, with the file in course) instead of the messages the old SSE stream sent. Before the job exists, the rows paint a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and the row writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, each entry reports `:queued` ("Queued"); the `:start`/`:retry` events move it to bare progress, and only `:progress` carries a milestone. The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line any more: each row carries its own status. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients. - File creation/duplication strips file `:data` before putting file summaries into dashboard state. ## Viewer diff --git a/backend/src/app/jobs.clj b/backend/src/app/jobs.clj index 19df02cc9b..caf19b6f4e 100644 --- a/backend/src/app/jobs.clj +++ b/backend/src/app/jobs.clj @@ -9,11 +9,15 @@ Every job is defined as a job-def map returned by the `ig/init-key` of its module: {::name, ::schema, ::handler, ::decoder, ::validator} — decoder and - validator are precompiled at init time. A job-def may also carry the - optional metadata {::family, ::resource-role}, which the consumers of the - job (quotes, storage helpers) read from the registry: the substrate never - reads them. The registry of job-defs is plain integrant wiring (::jobs/defs); - submit, dispatch and management consume it by reference. + validator are precompiled at init time. A def whose result needs its types + back declares the optional ::result-schema: the substrate compiles the + decoder from that schema and checks a result against it before storing it, + while a def without one answers with the JSON the row holds. A job-def may + also carry the optional metadata {::family, ::resource-role}, which the + consumers of the job (quotes, storage helpers) read from the registry: the + substrate never reads them. The registry of job-defs is plain integrant + wiring (::jobs/defs); submit, dispatch and management consume it by + reference. Two execution modes are provided: - `submit` (durable): validates + JSON-encodes params and inserts a row @@ -24,6 +28,11 @@ Params payloads are stored as plain JSON (not transit) in the `params` jsonb column and decoded back to typed Clojure values using the job-def decoder. + Results are plain JSON in the `result` jsonb column: they are decoded with the + decoder the substrate compiled from the schema the def declares, and a result + that does not conform to that schema is refused before it is stored. Without a + schema a uuid comes back as the text it is on disk and a uuid used as a map + key as a keyword, so a def that keys a result map by uuid must declare it. Any job that can run longer than `:jobs-lease` must call `heartbeat` on its loop, otherwise the dispatcher marks it `aborted` (a system-side @@ -151,7 +160,14 @@ ;; implementation stays [cfg params]. [::handler ::sm/fn] [::decoder ::sm/fn] - [::validator ::sm/fn]]) + [::validator ::sm/fn] + ;; A result crosses a plain JSON column on its way out of the job, so a + ;; uuid comes back as the text it is on disk and a uuid used as a map + ;; key comes back as a keyword. A def that declares the schema of its + ;; result gets its types back when the row is read (the substrate + ;; compiles the decoder from the schema), and its result is checked + ;; against that schema before it is stored. + [::result-schema {:optional true} any?]]) (def ^:private schema:job-defs [:map-of :keyword schema:job-def]) @@ -189,11 +205,24 @@ :job (d/name name) :expected (d/name (::name job-def)))))) +(defn- compile-result-schema + "Compile the decoder of a job-def from the result schema it declares, so the + pair cannot drift from the schema and a def without one keeps working + untouched. The write path checks the result against the schema itself." + [job-def] + (if-let [schema (::result-schema job-def)] + (assoc job-def ::result-decoder (sm/decoder schema sm/json-transformer)) + job-def)) + (defmethod ig/init-key ::defs [_ defs] - (reset! defs-registry defs) - (l/inf :hint "job definitions initialized" :jobs (count defs)) - defs) + (let [defs (reduce-kv (fn [acc name job-def] + (assoc acc name (compile-result-schema job-def))) + {} + defs)] + (reset! defs-registry defs) + (l/inf :hint "job definitions initialized" :jobs (count defs)) + defs)) (defmethod ig/halt-key! ::defs [_ defs] @@ -222,6 +251,32 @@ db/decode-json-pgobject) ((::decoder job-def)))) +(defn decode-result + "Decode the raw JSON result (pgobject or decoded map) with the decoder the + substrate compiled from the result schema of the job-def. A def without one + answers with the JSON the row holds: every key keywordized and every uuid as + the text it is on disk." + [job-def result] + (when (some? result) + (let [value (cond-> result + (db/pgobject? result) + db/decode-json-pgobject)] + (if-let [decoder (::result-decoder job-def)] + (decoder value) + value)))) + +(defn- check-result + "Validate the result of a job against the schema its job-def declares, with + the malli explanation on failure. A job-def without a result schema has + nothing to check, and so has a job whose definition is gone." + [job-def result] + (when-let [schema (::result-schema job-def)] + (sm/check schema result + :hint "invalid result of a job" + :type :validation + :code :invalid-result)) + result) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SUBMIT API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -432,11 +487,21 @@ (decode-json-col :result) (decode-json-col :error))) +(defn- decode-result-of + "Decode the result of a row with the decoder of the job-def that owns it. + A kind the registry no longer knows (a retired job-def) answers with the + row untouched instead of raising." + [cfg row] + (if-let [job-def (get (get-defs cfg) (keyword (:name row)))] + (update row :result #(decode-result job-def %)) + row)) + (defn get-job - "Retrieve the job row (with the raw JSON params decoded to a plain map)." + "Retrieve the job row, with params and result decoded to plain maps (the + result only when its job-def declares a result decoder)." [cfg job-id] - (some-> (db/get* cfg :job {:id job-id}) - (decode-row))) + (when-let [row (db/get* cfg :job {:id job-id})] + (decode-result-of cfg (decode-row row)))) (defn get-user-status "Map the internal job status to the user-facing status." @@ -1058,7 +1123,8 @@ - `:job-id` the job to complete. - `:result` the JSON-encodable result, nil when the job returns - nothing. + nothing; a result that does not conform to the schema + its job-def declares is refused. - `:resource-id` optional storage object to associate with the job. A resource is only ever set, never replaced: passing one for a job that already has a resource is a validation @@ -1073,11 +1139,13 @@ aborted by the dispatcher — is never overwritten) and writes no event. Returns the number of affected rows. - The row is read locked inside that same transaction, so the two rules - are applied in a fixed order against the row and not against the - clock: a job that is no longer completable is skipped, and only a job - that is still completable can be refused for its resource. A - concurrent completion waits for the lock and then sees the outcome." + The row is read locked inside that same transaction, so the rules are + applied in a fixed order against the row and not against the clock: a + job that is no longer completable is skipped, only a job that is still + completable can be refused for its resource, and the result is checked + against the schema its job-def declares before it is stored (a def + without one has nothing to check). A concurrent completion waits for + the lock and then sees the outcome." [cfg & {:keys [job-id result resource-id]}] (assert (mtx/instance cfg) "missing ::mtx/metrics on the jobs cfg") (check-resource-id resource-id) @@ -1097,6 +1165,9 @@ :hint "the job already has a resource, it cannot be replaced" :job-id job-id :resource-id (:resource-id job))) + (check-result (get (get-defs tx-cfg) + (keyword (:name job))) + result) (if (db/exec-one! conn [sql:complete-job (ct/now) (ct/now) encoded resource-id job-id]) (do diff --git a/backend/src/app/rpc/commands/jobs.clj b/backend/src/app/rpc/commands/jobs.clj index 54c7c352ed..c3f590315b 100644 --- a/backend/src/app/rpc/commands/jobs.clj +++ b/backend/src/app/rpc/commands/jobs.clj @@ -330,11 +330,15 @@ "Read a job the caller created: its state and, once it is over, what it produced or why it failed. - The result of a finished job is the plain JSON the row holds, because a - result has no schema of its own like the params do: a uuid comes back as - the text it is on disk. The progress and the history of the job are not - part of the answer: a client that follows a job takes its progress from - the websocket, and the event log still has no read surface." + The result of a finished job is the JSON the row holds, decoded with the + result schema its job-def declares: a job that keys its result by uuid + (the import resolution) answers with the types it stored, and a job whose + job-def declares no schema answers with the JSON as it is on disk, where a + uuid is text and a uuid used as a map key is a keyword. A result that does + not conform to the declared schema never reaches the column: the substrate + validates it when the job is completed. The progress and the history of + the job are not part of the answer: a client that follows a job takes its + progress from the websocket, and the event log still has no read surface." {::doc/added "2.20" ::sm/params schema:get-job ::sm/result schema:job-detail} diff --git a/backend/src/app/rpc/management/jobs.clj b/backend/src/app/rpc/management/jobs.clj index 1e71acb101..f5fc250c96 100644 --- a/backend/src/app/rpc/management/jobs.clj +++ b/backend/src/app/rpc/management/jobs.clj @@ -106,7 +106,10 @@ [:job-id ::sm/uuid] ;; Per-job result: any JSON-encodable value, nil when the job returns ;; nothing. Nested maps and vectors are allowed; unserializable - ;; values are dropped to nil by jobs/encode-result with a warning. + ;; values are dropped to nil by jobs/encode-result with a warning. A + ;; job whose job-def declares a result schema has its result validated + ;; against it: one that does not conform is rejected and the job is + ;; not completed. [:result [:maybe :any]] ;; Storage object the worker produced for this job. A resource is only ;; set, never replaced: a job that already has one is rejected. @@ -117,6 +120,13 @@ [:action [:enum :run :skip]]]) (sv/defmethod ::complete-job + "Complete a job on behalf of the worker that ran it, with the result it + produced and, when it wrote one, the resource it stores. + + The result of a job whose job-def declares a result schema is validated + against it before it is stored: one that does not conform is rejected and + the job is not completed. A job that is no longer completable is skipped, + and nothing is validated for it." {::doc/added "2.20" ::sm/params schema:complete-job-params ::sm/result schema:complete-job-result diff --git a/backend/src/app/tasks/import_binfile.clj b/backend/src/app/tasks/import_binfile.clj index 5dc286db08..754bb4bf25 100644 --- a/backend/src/app/tasks/import_binfile.clj +++ b/backend/src/app/tasks/import_binfile.clj @@ -44,12 +44,53 @@ [:map-of ::sm/uuid [:string {:max 250}]]]] [:version {:optional true} [:enum 1 3]]]) +(def schema:candidate + "A library of the instance whose slugified name matches the one the package + used and is therefore offered to the user as a replacement." + [:map + [:id ::sm/uuid] + [:name :string] + [:project-id ::sm/uuid] + [:project-name :string]]) + +(def schema:done-entry + "A library of the package that was linked while it was read." + [:map + [:id ::sm/uuid] + [:name :string] + [:linked-to ::sm/uuid]]) + +(def schema:pending-entry + "A library of the package that needs the user to choose a replacement: + candidates are the libraries of the instance with the same slugified name + (`bfc/find-shared-files-by-slug`)." + [:map + [:id ::sm/uuid] + [:name :string] + [:candidates [:vector schema:candidate]]]) + +(def schema:resolution-entry + "The libraries one imported file uses, split in the ones already linked + and the ones pending resolution." + [:map + [:id ::sm/uuid] + [:name :string] + [:done [:vector schema:done-entry]] + [:pending [:vector schema:pending-entry]]]) + +(def schema:resolution + "The resolution of every imported file, keyed by its new file id. The key + is a uuid, so the map needs this schema to come back typed: the result + column is plain JSON, and without a decoder every key of the map would be + read back as a keyword." + [:map-of ::sm/uuid schema:resolution-entry]) + (def schema:result "What the job stores when the import succeeds: the files it created and how the libraries of the package were resolved." [:map {:title "import-binfile-result" :closed true} [:file-ids [:vector ::sm/uuid]] - [:resolution {:optional true} :map] + [:resolution {:optional true} schema:resolution] [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:version [:enum 1 3]]]) @@ -66,7 +107,11 @@ The edition permission is checked again here, and not only when the job was created: it can be revoked while the job waits in its queue. The core runs inside a transaction, so a cancellation detected between units - rolls the whole import back. + rolls the whole import back, and the result is checked against its schema + inside that same transaction: a result the contract does not accept rolls + the import back instead of leaving half of a file imported. The substrate + checks the result again when the job is completed, which is the only net + for a job that does not validate its own result. The package is the input of the job: it is released and its temporary copy deleted once the job is terminal, whatever the outcome. A crash is @@ -92,17 +137,18 @@ ;; the core joins this transaction: it ;; is the one that decides whether the ;; whole import is kept or rolled back - (bfj/import-files tx-cfg context - {:profile-id profile-id - :project-id project-id - :team team - :name (:name params) - :input input - :version (:version params)})))] + (check-result + (assoc (bfj/import-files tx-cfg context + {:profile-id profile-id + :project-id project-id + :team team + :name (:name params) + :input input + :version (:version params)}) + :name (:name params) + :version (:version params)))))] - (check-result (assoc result - :name (:name params) - :version (:version params)))) + result) (finally (fs/delete input) @@ -123,4 +169,9 @@ ::jobs/handler (fn [context params] (execute-import cfg context params)) ::jobs/decoder (sm/decoder schema:params sm/json-transformer) - ::jobs/validator (sm/validator schema:params)}) + ::jobs/validator (sm/validator schema:params) + ;; The resolution is keyed by file id: without the result schema the + ;; keys come back as keywords and the dashboard wizard can no longer + ;; match them with the file ids it holds. The substrate compiles the + ;; decoder from it and checks the result against it before storing it. + ::jobs/result-schema schema:result}) diff --git a/backend/test/backend_tests/binfile_jobs_test.clj b/backend/test/backend_tests/binfile_jobs_test.clj index 7b4619c96c..e03cb79421 100644 --- a/backend/test/backend_tests/binfile_jobs_test.clj +++ b/backend/test/backend_tests/binfile_jobs_test.clj @@ -13,7 +13,6 @@ [app.binfile.v3 :as v3] [app.common.time :as ct] [app.common.uuid :as uuid] - [app.config :as cf] [app.db :as db] [app.jobs :as jobs] [app.jobs.storage :as js] @@ -49,27 +48,6 @@ (assoc ::bfc/input (fixture-path)) (v3/import-files!))) -(defn- make-job! - "A job row as the substrate writes it: running, owned by a profile and - holding a resource when the test needs one." - [profile-id resource-id] - (let [id (uuid/next)] - (th/db-insert! :job {:id id - :name "export-binfile" - :tenant (cf/get :tenant) - :queue "binfile" - :params (db/json {}) - :priority 100 - :max-retries 0 - :retry-num 0 - :status "running" - :profile-id profile-id - :resource-id resource-id - :scheduled-at (ct/now) - :created-at (ct/now) - :modified-at (ct/now)}) - id)) - (defn- job-cfg "The system cfg as the runner hands it to a handler. The job id is NOT bound here: the adapter takes it from the context, because the progress @@ -90,6 +68,46 @@ job-id]) (mapv #(jobs/decode-progress (:payload %))))) +(defn- export-link-later-package + "A package of one file that uses a shared library, exported so the + library is resolved while importing. The library of the package is gone + afterwards and two replacements share its name, so the import cannot + link it by itself and leaves the file pending resolution." + [profile] + (let [library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + (v3/export-files! (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + {:library-id (:id library) + :output output})) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ADAPTER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -97,7 +115,7 @@ (t/deftest export-through-the-adapter-produces-the-artifact (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) output (tmp/tempfile* :suffix ".penpot")] (bfj/export-files (job-cfg) (job-context job-id) @@ -129,7 +147,8 @@ {:content (sto/content (fixture-path)) :filename "package.penpot" :mtype "application/zip"}) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) context (job-context job-id) result (bfj/import-files (job-cfg) context {:profile-id (:id profile) @@ -148,7 +167,7 @@ (t/deftest the-adapter-composes-a-milestone-per-tap (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) reports (atom [])] ;; the substrate throttles the durable events, so the whole stream of @@ -183,7 +202,7 @@ (t/deftest a-tap-carries-the-outer-counters-in-course (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) taps (atom [])] ;; the taps themselves, before the adapter turns them into events @@ -242,7 +261,8 @@ {:content (sto/content (fixture-path)) :filename "package.penpot" :mtype "application/zip"}) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) context (job-context job-id) reports (atom [])] @@ -278,7 +298,7 @@ (t/deftest a-job-that-is-no-longer-active-stops-the-work (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) context (job-context job-id)] (t/testing "a running job is active" @@ -369,7 +389,7 @@ (t/deftest the-export-handler-runs-through-the-registry (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile) nil) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) result (jobs/invoke (-> th/*system* (assoc ::jobs/name :export-binfile) (assoc ::jobs/params {:file-ids #{file-id} @@ -389,7 +409,8 @@ {:content (sto/content (fixture-path)) :filename "package.penpot" :mtype "application/zip"}) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) result (jobs/invoke (-> th/*system* (assoc ::jobs/name :import-binfile) (assoc ::jobs/params {:project-id (:default-project-id profile) @@ -401,3 +422,122 @@ (t/is (pos? (count (:file-ids result))))) (t/testing "the handler reported progress without knowing the job id" (t/is (pos? (count (progress-events job-id))))))) + +(t/deftest the-import-result-keeps-the-type-of-its-ids + "The resolution of an import is a map keyed by the new file id, and it + crosses a plain JSON column: the result schema the job-def declares is + what keeps the keys and the values as uuids, which is what the dashboard + wizard matches against the ids it holds. Without the decoder the substrate + compiles from that schema, the key comes back as a keyword and the entry as + text, and this test fails." + (let [profile (th/create-profile* 1) + {:keys [library-id output]} (export-link-later-package profile) + + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content output) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) + result (jobs/invoke (-> th/*system* + (assoc ::jobs/name :import-binfile) + (assoc ::jobs/params {:project-id (:default-project-id profile) + :name "imported" + :version 3}) + (assoc ::jobs/context (job-context job-id))))] + + (t/testing "the result of the job is stored as the substrate does" + (t/is (= 1 (jobs/complete th/*system* :job-id job-id :result result)))) + + (let [stored (:result (jobs/get-job th/*system* job-id)) + [file-id file-res] (first (:resolution stored))] + + (t/testing "the resolution comes back keyed by the new file id" + (t/is (= 1 (count (:resolution stored)))) + (t/is (uuid? file-id)) + (t/is (= file-id (:id file-res)))) + + (t/testing "the ids of the entries keep their type" + (t/is (= [file-id] (:file-ids stored))) + (t/is (= [] (:done file-res))) + (t/is (= 1 (count (:pending file-res)))) + + (let [pending (first (:pending file-res))] + (t/is (= library-id (:id pending))) + (t/is (= 2 (count (:candidates pending)))) + (t/is (every? (comp uuid? :id) (:candidates pending))) + (t/is (every? (comp uuid? :project-id) (:candidates pending)))))))) + +(t/deftest a-package-without-external-libraries-reads-with-an-empty-resolution + (let [profile (th/create-profile* 1) + staged (js/put-resource th/*system* (:id profile) + {:content (sto/content (fixture-path)) + :filename "package.penpot" + :mtype "application/zip"}) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) + result (jobs/invoke (-> th/*system* + (assoc ::jobs/name :import-binfile) + (assoc ::jobs/params {:project-id (:default-project-id profile) + :name "imported" + :version 3}) + (assoc ::jobs/context (job-context job-id))))] + + (t/is (= 1 (jobs/complete th/*system* :job-id job-id :result result))) + + (let [stored (:result (jobs/get-job th/*system* job-id))] + + (t/testing "a package without external libraries resolves nothing" + (t/is (= {} (:resolution stored)))) + + (t/testing "and the file ids come back typed" + (t/is (= 1 (count (:file-ids stored)))) + (t/is (every? uuid? (:file-ids stored))))))) + +(t/deftest a-version-1-result-reads-without-a-resolution + "A version 1 import answers with the file ids and no resolution: the read + path decodes it with the schema of the job-def, which declares the + resolution as optional. Without that schema the file ids come back as text." + (let [profile (th/create-profile* 1) + file-id (uuid/next) + job-id (th/create-job* :import-binfile {:profile-id (:id profile)})] + + (t/is (= 1 (jobs/complete th/*system* :job-id job-id + :result {:file-ids [file-id] + :name "imported-v1" + :version 1}))) + + (let [stored (:result (jobs/get-job th/*system* job-id))] + + (t/testing "there is no resolution to decode" + (t/is (nil? (:resolution stored)))) + + (t/testing "and the file ids come back typed" + (t/is (= [file-id] (:file-ids stored))) + (t/is (= 1 (:version stored))))))) + +(t/deftest a-result-of-a-retired-job-def-reads-as-plain-json + "A registry that no longer knows the kind of the row answers with the JSON + the column holds, keywordized keys and uuid as text, instead of raising." + (let [profile (th/create-profile* 1) + file-id (uuid/next) + job-id (th/create-job* :import-binfile {:profile-id (:id profile)}) + key (keyword (str file-id))] + + (t/is (= 1 (jobs/complete th/*system* :job-id job-id + :result {:file-ids [file-id] + :resolution {file-id {:id file-id + :name "card.penpot" + :done [] + :pending []}} + :name "imported" + :version 3}))) + + (let [stored (:result (jobs/get-job (assoc th/*system* ::jobs/defs {}) job-id))] + + (t/testing "the resolution comes back as it is on disk" + (t/is (= [key] (keys (:resolution stored)))) + (t/is (= (str file-id) (get-in stored [:resolution key :id])))) + + (t/testing "and so do the file ids" + (t/is (= [(str file-id)] (:file-ids stored))))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 24759bae21..bbcc71173c 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -252,6 +252,30 @@ :features features} params))))))) +(defn create-job* + "A job row in the state a worker finds it: running, in the `binfile` queue, + owned by `profile-id` and holding `resource-id` when it consumes one. + `job-name` is the kind of the job (a keyword like the `::jobs/name` a caller + would pass, or the text the row holds) and it is what resolves the job-def + of the row when its result is read or checked." + [job-name {:keys [profile-id resource-id]}] + (let [id (uuid/next)] + (db/insert! *pool* :job {:id id + :name (name job-name) + :tenant (cf/get :tenant) + :queue "binfile" + :params (db/json {}) + :priority 100 + :max-retries 0 + :retry-num 0 + :status "running" + :profile-id profile-id + :resource-id resource-id + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + id)) + (defn mark-file-deleted* ([params] (mark-file-deleted* *system* params)) diff --git a/backend/test/backend_tests/jobs_test.clj b/backend/test/backend_tests/jobs_test.clj index 5dee48bbb3..15c7e6d83b 100644 --- a/backend/test/backend_tests/jobs_test.clj +++ b/backend/test/backend_tests/jobs_test.clj @@ -65,10 +65,31 @@ ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) ::jobs/validator (sm/validator schema:echo-params)}) +(def schema:typed-result + "A result with a uuid keyed map, the shape that needs a declared schema + to survive the plain JSON round trip of the `job.result` column." + [:map + [:file-ids [:vector ::sm/uuid]] + [:resolution [:map-of ::sm/uuid [:map [:id ::sm/uuid]]]]]) + +(defmethod ig/init-key ::typed-job + [_ cfg] + {::jobs/name :typed + ::jobs/schema schema:echo-params + ::jobs/handler (fn [_context params] + (echo-handler cfg params)) + ::jobs/decoder (sm/decoder schema:echo-params sm/json-transformer) + ::jobs/validator (sm/validator schema:echo-params) + ;; the substrate compiles the decoder from this schema and checks the + ;; result against it before storing it + ::jobs/result-schema schema:typed-result}) + (defn- get-job-defs [] - (-> {::jobs/defs {:echo (ig/ref ::echo-job)} - ::echo-job {::db/pool th/*pool*}} + (-> {::jobs/defs {:echo (ig/ref ::echo-job) + :typed (ig/ref ::typed-job)} + ::echo-job {::db/pool th/*pool*} + ::typed-job {::db/pool th/*pool*}} (ig/expand) (ig/init) (get ::jobs/defs))) @@ -1188,11 +1209,12 @@ (defn- mk-running "A job already claimed by a worker, ready for a terminal transition." - [cfg] - (let [job-id (jobs/submit cfg {::jobs/name :echo - ::jobs/params (make-params)})] - (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) - job-id)) + ([cfg] (mk-running cfg :echo)) + ([cfg name] + (let [job-id (jobs/submit cfg {::jobs/name name + ::jobs/params (make-params)})] + (jobs/claim cfg job-id (:scheduled-at (jobs/get-job cfg job-id))) + job-id))) (t/deftest lifecycle-writers-require-metrics ;; Every one of the six writers must check the metrics instance before @@ -1375,6 +1397,99 @@ (t/is (= 0 (jobs/cancel cfg job-id))) (t/is (= ["cancelled"] (get-outcomes job-id)))))) +(t/deftest get-job-decodes-the-result-with-the-schema-of-the-def + "A result with a declared schema comes back typed. Without it, the plain + JSON of the column keywordizes every key and leaves every uuid as text; + without the substrate compiling the decoder from that schema, the key of + the uuid map comes back as a keyword and this test fails." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg :typed) + file-id (uuid/next) + lib-id (uuid/next)] + + (t/is (= 1 (jobs/complete cfg :job-id job-id + :result {:file-ids [lib-id] + :resolution {file-id {:id file-id}}}))) + + (let [result (:result (jobs/get-job cfg job-id))] + (t/testing "the uuid keyed map keeps the type of its key and values" + (t/is (= [file-id] (keys (:resolution result)))) + (t/is (uuid? (:id (get (:resolution result) file-id))))) + + (t/testing "the vector of uuids keeps the type of its elements" + (t/is (= [lib-id] (:file-ids result))))))) + +(t/deftest get-job-leaves-a-result-without-a-schema-untouched + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg) + file-id (uuid/next)] + + (t/is (= 1 (jobs/complete cfg :job-id job-id + :result {:resolution {file-id {:id file-id}}}))) + + (t/testing "the raw JSON shape survives: keywordized keys, uuid as text" + (let [result (:result (jobs/get-job cfg job-id)) + key (keyword (str file-id))] + (t/is (= [key] (keys (:resolution result)))) + (t/is (= (str file-id) (get-in result [:resolution key :id]))))))) + +(t/deftest get-job-reads-a-row-whose-job-def-is-gone + (let [job-id (mk-running (make-cfg (get-job-defs))) + ;; a registry that no longer knows the kind of the row (a retired + ;; job-def): reading its result must not raise + row (jobs/get-job (make-cfg {}) job-id)] + (t/is (some? row)) + (t/is (= "echo" (:name row))))) + +(t/deftest complete-refuses-a-result-that-does-not-conform + "The schema a job-def declares is checked before the result is stored: the + row keeps no result and the job is not completed." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg :typed)] + + (t/is (thrown-with-msg? Exception #"invalid result of a job" + (jobs/complete cfg :job-id job-id + :result {:resolution "not-a-map"}))) + + (t/testing "the row was not completed and holds no result" + (let [row (jobs/get-job cfg job-id)] + (t/is (= "running" (:status row))) + (t/is (nil? (:result row))))) + + (t/testing "a job that is no longer completable is skipped without checking" + (th/db-update! :job {:status "aborted"} {:id job-id}) + (t/is (zero? (jobs/complete cfg :job-id job-id + :result {:resolution "not-a-map"})))))) + +(t/deftest a-stored-result-that-does-not-conform-is-read-decode-only + "A row written by another version may hold a result the schema of today no + longer accepts: reading it decodes and never raises." + (let [cfg (make-cfg (get-job-defs)) + job-id (mk-running cfg :typed)] + + (th/db-update! :job + {:status "completed" + :result (db/json {:resolution "not-a-map"})} + {:id job-id}) + + (let [row (jobs/get-job cfg job-id)] + (t/is (= "completed" (:status row))) + (t/is (= {:resolution "not-a-map"} (:result row)))))) + +(t/deftest decode-result-accepts-a-pgobject + (let [job-def (jobs/get-job-def (get (make-cfg (get-job-defs)) ::jobs/defs) :typed) + file-id (uuid/next)] + + (t/testing "a raw column value is decoded with the schema of the def" + (let [decoded (jobs/decode-result job-def + (db/json {:file-ids [file-id] + :resolution {file-id {:id file-id}}}))] + (t/is (= [file-id] (:file-ids decoded))) + (t/is (= [file-id] (keys (:resolution decoded)))))) + + (t/testing "no result answers nil" + (t/is (nil? (jobs/decode-result job-def nil)))))) + (t/deftest aborted-job-is-terminal-for-all-writers "An aborted job (system-side terminal set by the orphan sweep) cannot be moved by any lifecycle writer: first-terminal-wins, with no event and no diff --git a/backend/test/backend_tests/tasks/export_binfile_test.clj b/backend/test/backend_tests/tasks/export_binfile_test.clj index 80fcdedd36..40a67a549a 100644 --- a/backend/test/backend_tests/tasks/export_binfile_test.clj +++ b/backend/test/backend_tests/tasks/export_binfile_test.clj @@ -11,10 +11,6 @@ [app.binfile.common :as bfc] [app.binfile.v3 :as v3] [app.common.exceptions :as ex] - [app.common.time :as ct] - [app.common.uuid :as uuid] - [app.config :as cf] - [app.db :as db] [app.jobs :as jobs] [app.storage :as sto] [app.storage.tmp :as tmp] @@ -46,25 +42,6 @@ (assoc ::bfc/input (-> fixture io/resource jio/file)) (v3/import-files!))) -(defn- make-job! - "A job row as the substrate writes it: running and owned by a profile." - [profile-id] - (let [id (uuid/next)] - (th/db-insert! :job {:id id - :name "export-binfile" - :tenant (cf/get :tenant) - :queue "binfile" - :params (db/json {}) - :priority 100 - :max-retries 0 - :retry-num 0 - :status "running" - :profile-id profile-id - :scheduled-at (ct/now) - :created-at (ct/now) - :modified-at (ct/now)}) - id)) - (defn- run-export "Run the export handler of a job, as the runner does." [job-id params] @@ -99,7 +76,7 @@ (t/deftest the-export-handler-stores-the-artifact-of-its-job (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile)) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) result (run-export job-id {:file-ids #{file-id} :export-type :detach-libraries}) object (sto/get-object (get-storage) (:resource-id result))] @@ -136,7 +113,7 @@ (let [owner (th/create-profile* 1) other (th/create-profile* 2) file-id (first (:file-ids (import-fixture! owner))) - job-id (make-job! (:id other)) + job-id (th/create-job* :export-binfile {:profile-id (:id other)}) before (count-storage-objects)] (t/testing "a job whose owner cannot read a file does not export it" @@ -154,7 +131,7 @@ (t/deftest the-export-handler-leaves-nothing-when-the-storage-fails (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile)) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) before (count-storage-objects)] (with-redefs [sto/put-object! (fn [& _] @@ -177,7 +154,7 @@ (t/deftest the-export-handler-leaves-nothing-behind-when-the-job-is-cancelled (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) - job-id (make-job! (:id profile)) + job-id (th/create-job* :export-binfile {:profile-id (:id profile)}) before (count-storage-objects)] (th/db-update! :job {:status "cancelled"} {:id job-id}) diff --git a/backend/test/backend_tests/tasks/import_binfile_test.clj b/backend/test/backend_tests/tasks/import_binfile_test.clj index e2c619177f..eadc44104f 100644 --- a/backend/test/backend_tests/tasks/import_binfile_test.clj +++ b/backend/test/backend_tests/tasks/import_binfile_test.clj @@ -9,11 +9,10 @@ it leaves behind when it fails." (:require [app.binfile.common :as bfc] + [app.binfile.jobs :as bfj] [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] - [app.common.time :as ct] [app.common.uuid :as uuid] - [app.config :as cf] [app.db :as db] [app.jobs :as jobs] [app.jobs.storage :as js] @@ -65,27 +64,6 @@ :filename "package.penpot" :mtype "application/zip"})) -(defn- make-job! - "A job row as the substrate writes it: running, owned by a profile and - holding the package it consumes." - [profile-id resource-id] - (let [id (uuid/next)] - (th/db-insert! :job {:id id - :name "import-binfile" - :tenant (cf/get :tenant) - :queue "binfile" - :params (db/json {}) - :priority 100 - :max-retries 0 - :retry-num 0 - :status "running" - :profile-id profile-id - :resource-id resource-id - :scheduled-at (ct/now) - :created-at (ct/now) - :modified-at (ct/now)}) - id)) - (defn- run-import "Run the import handler of a job, as the runner does." [job-id params] @@ -119,7 +97,8 @@ (t/deftest the-import-handler-imports-the-package-of-its-job (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) result (run-import job-id {:project-id (:default-project-id profile) :name "imported" :version 3})] @@ -142,7 +121,8 @@ (let [profile (th/create-profile* 1) project-id (:default-project-id profile) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-modified-at project-id)] (run-import job-id {:project-id project-id @@ -157,7 +137,8 @@ (let [profile (th/create-profile* 1) file-id (first (:file-ids (import-fixture! profile))) staged (stage-package! profile (export-to-v1! file-id)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) result (run-import job-id {:project-id (:default-project-id profile) :name "imported-v1" :version 1})] @@ -171,7 +152,8 @@ staged (stage-package! other (fixture-path)) ;; the package belongs to `other`, but the destination project ;; belongs to `owner`: an import there is not allowed - job-id (make-job! (:id other) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id other) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id owner))] (t/testing "a job that cannot edit the destination does not import it" @@ -189,7 +171,8 @@ (t/deftest a-job-that-was-already-cancelled-never-imports (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id profile))] (th/db-update! :job {:status "cancelled"} {:id job-id}) @@ -207,7 +190,8 @@ (t/deftest an-import-cancelled-while-it-runs-leaves-nothing-behind (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id profile))] ;; the user cancels while the import is running: the first progress @@ -230,7 +214,8 @@ (t/deftest a-package-that-is-not-a-penpot-file-fails-without-writing (let [profile (th/create-profile* 1) staged (stage-package! profile (fixture-path)) - job-id (make-job! (:id profile) (:resource-id staged)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) before (project-files (:default-project-id profile))] ;; the bytes of the resource are replaced by something that is not a @@ -252,3 +237,32 @@ (t/testing "and the broken package is released too" (t/is (some? (:deleted-at (input-row (:resource-id broken))))))))) + +(t/deftest a-result-that-does-not-conform-leaves-nothing-behind + (let [profile (th/create-profile* 1) + project-id (:default-project-id profile) + staged (stage-package! profile (fixture-path)) + job-id (th/create-job* :import-binfile {:profile-id (:id profile) + :resource-id (:resource-id staged)}) + before (project-files project-id)] + + ;; the core does its work inside the transaction and answers with a result + ;; the schema of the task does not accept: the check runs before the + ;; transaction commits, so the work is rolled back with it + (with-redefs [bfj/import-files + (fn [tx-cfg _context _params] + (th/create-file* tx-cfg 7 {:profile-id (:id profile) + :project-id project-id + :is-shared false + :name "half imported"}) + {:file-ids [(uuid/next)] + :resolution "not-a-map"})] + + (t/testing "the handler refuses the result" + (t/is (thrown-with-msg? Exception #"invalid result of an import job" + (run-import job-id {:project-id project-id + :name "imported" + :version 3})))) + + (t/testing "and the import is rolled back: the destination keeps no files" + (t/is (= before (project-files project-id))))))) diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index e03d67dbe9..347364ff67 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -156,6 +156,20 @@ 0 resolution)) +(defn- pending-library-links + "The library links the wizard must create for the files of a resolution: + one per pending entry the user chose a candidate for. The file id comes + from the entry and never from the key of the map: what a key holds depends + on the result schema of the job-def that wrote it, while the wizard indexes + its selection by the id of the entry." + [resolution selection] + (->> (vals resolution) + (mapcat (fn [{:keys [id pending]}] + (keep (fn [entry] + (when-let [library-id (get-in selection [id (:id entry)])] + {:file-id id :library-id library-id})) + pending))))) + (defn- analyze-entries [state entries] (->> (imp/analyze entries) @@ -866,22 +880,17 @@ (mf/deps resolution selection on-finish-import) (fn [event] (dom/prevent-default event) - (let [slc selection] - ;; For each file with pending candidates, link it to the selected libraries - (->> (rx/from (seq resolution)) - (rx/merge-map - (fn [[file-id resolution-file]] - (->> (rx/from (:pending resolution-file)) - (rx/merge-map - (fn [{:keys [id]}] - (when-let [selected-lib (get-in slc [file-id id])] - (link-files-to-library! [file-id] selected-lib))))))) - (rx/subs! (constantly nil) - (constantly nil) - (fn [] - (st/emit! (modal/hide)) - (when (fn? on-finish-import) - (on-finish-import)))))))) + ;; For each file with pending candidates, link it to the selected libraries + (->> (rx/from (pending-library-links resolution selection)) + (rx/merge-map + (fn [{:keys [file-id library-id]}] + (link-files-to-library! [file-id] library-id))) + (rx/subs! (constantly nil) + (constantly nil) + (fn [] + (st/emit! (modal/hide)) + (when (fn? on-finish-import) + (on-finish-import))))))) on-wizard-next (mf/use-fn diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f742f81093..93bae2969f 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -115,6 +115,7 @@ [frontend-tests.ui.comment-input-ime-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] + [frontend-tests.ui.dashboard-import-linking-test] [frontend-tests.ui.drawarea-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.error-boundary-test] @@ -271,6 +272,7 @@ 'frontend-tests.ui.comment-input-ime-test 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test + 'frontend-tests.ui.dashboard-import-linking-test 'frontend-tests.ui.drawarea-test 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.error-boundary-test diff --git a/frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs b/frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs new file mode 100644 index 0000000000..84d8585719 --- /dev/null +++ b/frontend/test/frontend_tests/ui/dashboard_import_linking_test.cljs @@ -0,0 +1,128 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.dashboard-import-linking-test + "The links the import wizard creates for a file of the package whose + library the auto-link could not resolve. The resolution map comes from the + job result, so the type of its keys is not something the wizard can rely + on: the file id of a link must come from the entry and never from the key + of the map." + (:require + [app.common.uuid :as uuid] + [app.main.ui.dashboard.import :as import] + [cljs.test :as t :include-macros true])) + +(def ^:private pending-library-links + @#'import/pending-library-links) + +(defn- pending-entry + "One library of the package the user has to resolve, with a candidate." + [library-id] + {:id library-id + :name "Tokens" + :candidates [{:id (uuid/next) + :name "Tokens" + :project-id (uuid/next) + :project-name "Design system"}]}) + +(defn- file-resolution + "One file of a resolution, with a single library pending resolution." + [file-id library-id] + {:id file-id + :name "card.penpot" + :done [] + :pending [(pending-entry library-id)]}) + +(t/deftest the-file-id-of-a-link-comes-from-the-entry-and-not-from-the-key + (let [file-id (uuid/next) + old-lib (uuid/next) + new-lib (uuid/next) + ;; the shape a result without a declared schema has: the key of the + ;; map is a keyword and the entry carries the same id as text + resolution {(keyword (str file-id)) (file-resolution (str file-id) (str old-lib))} + selection {(str file-id) {(str old-lib) (str new-lib)}}] + + (t/is (= [{:file-id (str file-id) :library-id (str new-lib)}] + (pending-library-links resolution selection))))) + +(t/deftest the-links-are-the-same-with-a-typed-resolution + (let [file-id (uuid/next) + old-lib (uuid/next) + new-lib (uuid/next) + resolution {file-id (file-resolution file-id old-lib)} + selection {file-id {old-lib new-lib}}] + + (t/is (= [{:file-id file-id :library-id new-lib}] + (pending-library-links resolution selection))))) + +(t/deftest a-file-with-several-pending-libraries-creates-one-link-each + (let [file-id (uuid/next) + lib-a (uuid/next) + lib-b (uuid/next) + new-a (uuid/next) + new-b (uuid/next) + resolution {file-id {:id file-id + :name "card.penpot" + :done [] + :pending [(pending-entry lib-a) + (pending-entry lib-b)]}} + selection {file-id {lib-a new-a + lib-b new-b}}] + + (t/testing "both entries the user resolved become a link" + (t/is (= #{{:file-id file-id :library-id new-a} + {:file-id file-id :library-id new-b}} + (set (pending-library-links resolution selection))))))) + +(t/deftest a-partial-selection-creates-only-the-chosen-link + (let [file-id (uuid/next) + lib-a (uuid/next) + lib-b (uuid/next) + new-a (uuid/next) + resolution {file-id {:id file-id + :name "card.penpot" + :done [] + :pending [(pending-entry lib-a) + (pending-entry lib-b)]}} + selection {file-id {lib-a new-a}}] + + (t/testing "the entry with no candidate chosen emits nothing" + (t/is (= [{:file-id file-id :library-id new-a}] + (pending-library-links resolution selection)))))) + +(t/deftest a-done-entry-creates-no-link + (let [file-id (uuid/next) + library (uuid/next)] + + (t/testing "a file whose libraries were auto-linked has nothing to emit" + (t/is (= [] (pending-library-links + {file-id {:id file-id + :name "card.penpot" + :done [{:id library + :name "Tokens" + :linked-to library}] + :pending []}} + {file-id {library (uuid/next)}})))))) + +(t/deftest pending-entries-without-a-selection-create-no-link + (let [file-id (uuid/next) + old-lib (uuid/next)] + + (t/testing "an entry the user skipped creates nothing" + (t/is (= [] (pending-library-links + {file-id (file-resolution file-id old-lib)} + {file-id {}})))) + + (t/testing "a file with nothing pending creates nothing" + (t/is (= [] (pending-library-links + {file-id {:id file-id + :name "card.penpot" + :done [] + :pending []}} + {file-id {old-lib (uuid/next)}})))) + + (t/testing "an empty resolution creates nothing" + (t/is (= [] (pending-library-links {} {})))))) From 47f0e1574434c4ab04b1f1a2dcb0d8e94126d9a1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:05:58 +0200 Subject: [PATCH 096/325] :recycle: Use DS icon buttons in the align options (#12366) * :recycle: Use DS icon buttons in the align options Replace the eight raw align and distribute buttons with icon-button* and DS icons. The title text moves to aria-label, so the DS tooltip shows the same text and shortcut. Disabled distribute buttons now use the DS disabled color. Add a visual test that selects two shapes so the align options show. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from the align options The align buttons are DS icon buttons now, so the .align-button rules and their placeholders go away. Move the row height to a DS size token and drop the refactor/ import. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 24 ++++ .../sidebar/options/menus/align.cljs | 107 ++++++++---------- .../sidebar/options/menus/align.scss | 35 +----- 3 files changed, 76 insertions(+), 90 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index df766736a0..6604c94ffc 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -84,6 +84,30 @@ test.describe("Design tab", () => { await expect(workspace.page).toHaveScreenshot(); }); + + test("Shows the align options when selecting two shapes", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await workspace.rectShapeButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + await workspace.clickLeafLayer("Rectangle"); + await page.keyboard.press("Control+D"); + await expect(workspace.layers.getByText("Rectangle")).toHaveCount(2); + await page.keyboard.press("Control+A"); + + await expect( + workspace.rightSidebar.getByRole("button", { name: /^Align left/ }), + ).toBeVisible(); + // The align options are a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); }); test.describe("Assets tab", () => { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs index 3ae778553f..7897e4c81e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs @@ -11,7 +11,8 @@ [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.shortcuts :as sc] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -57,67 +58,59 @@ (when (or path-edit? (not (and disabled-align disabled-distribute))) [:div {:class (stl/css :align-options)} [:div {:class (stl/css :align-group-horizontal)} - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.hleft" (sc/get-tooltip :align-left)) - :data-value "hleft" - :on-click align-objects} - deprecated-icon/align-left] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hleft" (sc/get-tooltip :align-left)) + :icon i/align-left + :disabled disabled-align + :data-value "hleft" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.hcenter" (sc/get-tooltip :align-hcenter)) - :data-value "hcenter" - :on-click align-objects} - deprecated-icon/align-horizontal-center] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hcenter" (sc/get-tooltip :align-hcenter)) + :icon i/align-horizontal-center + :disabled disabled-align + :data-value "hcenter" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.hright" (sc/get-tooltip :align-right)) - :data-value "hright" - :on-click align-objects} - deprecated-icon/align-right] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hright" (sc/get-tooltip :align-right)) + :icon i/align-right + :disabled disabled-align + :data-value "hright" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-distribute) - :disabled disabled-distribute - :title (tr "workspace.align.hdistribute" (sc/get-tooltip :h-distribute)) - :data-value "horizontal" - :on-click distribute-objects} - deprecated-icon/distribute-horizontally]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.hdistribute" (sc/get-tooltip :h-distribute)) + :icon i/distribute-horizontally + :disabled disabled-distribute + :data-value "horizontal" + :on-click distribute-objects}]] [:div {:class (stl/css :align-group-vertical)} - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.vtop" (sc/get-tooltip :align-top)) - :data-value "vtop" - :on-click align-objects} - deprecated-icon/align-top] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vtop" (sc/get-tooltip :align-top)) + :icon i/align-top + :disabled disabled-align + :data-value "vtop" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.vcenter" (sc/get-tooltip :align-vcenter)) - :data-value "vcenter" - :on-click align-objects} - deprecated-icon/align-vertical-center] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vcenter" (sc/get-tooltip :align-vcenter)) + :icon i/align-vertical-center + :disabled disabled-align + :data-value "vcenter" + :on-click align-objects}] - [:button {:class (stl/css-case :align-button true - :disabled disabled-align) - :disabled disabled-align - :title (tr "workspace.align.vbottom" (sc/get-tooltip :align-bottom)) - :data-value "vbottom" - :on-click align-objects} - deprecated-icon/align-bottom] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vbottom" (sc/get-tooltip :align-bottom)) + :icon i/align-bottom + :disabled disabled-align + :data-value "vbottom" + :on-click align-objects}] - [:button {:title (tr "workspace.align.vdistribute" (sc/get-tooltip :v-distribute)) - :class (stl/css-case :align-button true - :disabled disabled-distribute) - :disabled disabled-distribute - :data-value "vertical" - :on-click distribute-objects} - deprecated-icon/distribute-vertical-spacing]]]))) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.align.vdistribute" (sc/get-tooltip :v-distribute)) + :icon i/distribute-vertical-spacing + :disabled disabled-distribute + :data-value "vertical" + :on-click distribute-objects}]]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss index 135544aa70..4386af7d6b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss @@ -4,13 +4,13 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; .align-options { @include sidebar.option-grid-structure; - height: deprecated.$s-32; + block-size: $sz-32; } .align-group-horizontal, @@ -27,34 +27,3 @@ .align-group-vertical { grid-column: 5 / span 4; } - -.align-button { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-32; - padding: 0; - border-radius: deprecated.$br-8; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.disabled { - cursor: default; - - svg { - stroke: var(--button-foreground-color-disabled); - } - - &:hover { - background-color: var(--panel-background-color); - - svg { - stroke: var(--button-foreground-color-disabled); - } - } - } -} From d26a40163786aa64e4af7b31d68bf2620e632dd3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:06:29 +0200 Subject: [PATCH 097/325] :recycle: Use DS buttons for more colors in the selected colors (#12369) * :recycle: Use DS buttons for more colors in the selected colors Replace the three raw "more colors" buttons (library colors, colors and color tokens) with the DS secondary button. Add a visual test that selects a board with more than three colors so the button shows. AI-assisted-by: claude-opus-5-5 * :recycle: Move selected colors styles to DS tokens The "more colors" buttons are DS buttons now, so their placeholder and typography mixin go away; the class only centers the label, since the column stretches the button. Delete the unused .add-fill rule, move spacing to DS tokens and logical properties, and drop the refactor/ import. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 24 ++++++++++++++ .../options/menus/color_selection.cljs | 16 ++++++---- .../options/menus/color_selection.scss | 32 ++++++------------- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 6604c94ffc..a99ea0b020 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -188,3 +188,27 @@ test.describe("Palette", () => { ).toBeVisible(); }); }); + +test.describe("Selected colors", () => { + test("Shows the more colors button", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await page.keyboard.press("Control+A"); + + await expect( + workspace.rightSidebar.getByRole("button", { name: "More colors" }), + ).toBeVisible(); + // The button is a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index 5db6007cf7..d72f730775 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -14,6 +14,7 @@ [app.main.data.workspace.tokens.application :as dwta] [app.main.store :as st] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -247,8 +248,9 @@ :origin :color-selection :on-close on-close}])) (when (and (false? @expand-lib-color) (< 3 (count library-colors))) - [:button {:class (stl/css :more-colors-btn) - :on-click #(reset! expand-lib-color true)} + [:> button* {:variant "secondary" + :class (stl/css :more-colors-btn) + :on-click #(reset! expand-lib-color true)} (tr "workspace.options.more-lib-colors")])] [:div {:class (stl/css :selected-color-group)} @@ -265,8 +267,9 @@ :on-close on-close}]) (when (and (false? @expand-color) (< 3 (count colors))) - [:button {:class (stl/css :more-colors-btn) - :on-click #(reset! expand-color true)} + [:> button* {:variant "secondary" + :class (stl/css :more-colors-btn) + :on-click #(reset! expand-color true)} (tr "workspace.options.more-colors")])] [:div {:class (stl/css :selected-color-group)} @@ -288,6 +291,7 @@ (when (and (false? @expand-token-color) (< 3 (count token-colors))) - [:button {:class (stl/css :more-colors-btn) - :on-click #(reset! expand-token-color true)} + [:> button* {:variant "secondary" + :class (stl/css :more-colors-btn) + :on-click #(reset! expand-token-color true)} (tr "workspace.options.more-token-colors")])]])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss index 15da32944b..f49e29e66f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss @@ -4,7 +4,6 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; .element-set { @@ -16,36 +15,25 @@ } .title-spacing-selected-colors { - padding-left: deprecated.$s-2; + padding-inline-start: var(--sp-xxs); margin: 0; } -.add-fill { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } -} - .element-content { grid-column: span 8; - - @include deprecated.flex-column; - - margin-bottom: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-end: var(--sp-s); } .selected-color-group { - @include deprecated.flex-column; + display: flex; + flex-direction: column; + gap: var(--sp-xs); } +// The DS button is stretched by the column; center its label. .more-colors-btn { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - height: deprecated.$s-32; + justify-content: center; } From 539983db2fd3033303d7c86be161aed94f6448bd Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:07:04 +0200 Subject: [PATCH 098/325] :recycle: Use a DS icon button for flatten in the boolean options (#12368) * :recycle: Use a DS icon button for flatten in the boolean options Replace the raw flatten button and its deprecated icon with icon-button* and the DS boolean-flatten icon. The title text moves to aria-label, so the DS tooltip shows it. The grid column goes on the tooltip wrapper through tooltip-class. Add a visual test that selects two shapes so the boolean options show. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from the boolean options The flatten button is a DS icon button now, so its placeholders, sizes and icon colors go away; only the grid column stays, on the tooltip wrapper. Use block-size and drop the refactor/ import. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 25 +++++++++++++++++ .../workspace/sidebar/options/menus/bool.cljs | 19 +++++-------- .../workspace/sidebar/options/menus/bool.scss | 28 +------------------ 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index a99ea0b020..bff404fdd3 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -54,6 +54,31 @@ test.describe("Viewport", () => { }); test.describe("Design tab", () => { + test("Shows the boolean options when selecting two shapes", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await workspace.rectShapeButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + await workspace.clickLeafLayer("Rectangle"); + await page.keyboard.press("Control+D"); + await expect(workspace.layers.getByText("Rectangle")).toHaveCount(2); + await page.keyboard.press("Control+A"); + + await expect( + workspace.rightSidebar.getByRole("button", { name: "Flatten" }), + ).toBeVisible(); + // The boolean options are a small part of the sidebar: compare every + // pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + test("Shows the design tab when selecting a shape", async ({ page }) => { const workspace = new WorkspacePage(page); await workspace.setupEmptyFile(); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs index 46801115ad..84345ef667 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs @@ -16,14 +16,11 @@ [app.main.features :as features] [app.main.store :as st] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private flatten-icon - (deprecated-icon/icon-xref :boolean-flatten (stl/css :flatten-icon))) - (mf/defc bool-options* [{:keys [total-selected shapes shapes-with-children]}] (let [head (first shapes) @@ -100,11 +97,9 @@ :title (str (tr "workspace.shape.menu.exclude") " (" (sc/get-tooltip :bool-exclude) ")") :id "bool-opt-exclude"}]]] - [:button - {:title (tr "workspace.shape.menu.flatten") - :class (stl/css-case - :flatten-button true - :disabled disabled-flatten) - :disabled disabled-flatten - :on-click flatten-objects} - flatten-icon]]))) + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :flatten-button) + :aria-label (tr "workspace.shape.menu.flatten") + :icon i/boolean-flatten + :disabled disabled-flatten + :on-click flatten-objects}]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index 50c8ae0625..2a03d0fd51 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -4,13 +4,12 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; .boolean-options { @include sidebar.option-grid-structure; - height: var(--sp-xxxl); + block-size: var(--sp-xxxl); } .bool-group { @@ -20,32 +19,7 @@ } .flatten-button { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-32; - border-radius: deprecated.$br-8; grid-column: 5 / span 1; - - --flatten-icon-foreground-color: var(--icon-foreground); - - &.disabled { - cursor: default; - - --flatten-icon-foreground-color: var(--button-foreground-color-disabled); - - &:hover { - background-color: var(--panel-background-color); - - --flatten-icon-foreground-color: var(--button-foreground-color-disabled); - } - } -} - -.flatten-icon { - @extend %button-icon; - - stroke: var(--flatten-icon-foreground-color); } .boolean-radio-btn { From ab7a994d6c04ab5ea0b66009f5c401b6b9f816fa Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 15 Oct 2026 20:08:48 +0200 Subject: [PATCH 099/325] :bug: Fix libraries modal visual test (#12367) The libraries modal now opens on the "This file" tab, so the visual test timed out looking for the shared library. Open the "Libraries" tab first, as the functional test does, mock the file with the library linked, and check the "Added" label instead of the old "File library" text. AI-assisted-by: claude-opus-5-5 --- frontend/playwright/ui/visual-specs/workspace.spec.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index bff404fdd3..f074bc20a2 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -158,10 +158,15 @@ test.describe("Assets tab", () => { await workspace.openLibrariesModal(); await expect(workspace.page).toHaveScreenshot(); + await workspace.librariesModal + .getByRole("tab", { name: "Libraries" }) + .click(); + await expect(workspace.page).toHaveScreenshot(); + + // Now the get-file call should return a library + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-library.json"); await workspace.clickLibrary("Testing library 1"); - await expect( - workspace.librariesModal.getByText("File library"), - ).toBeVisible(); + await expect(workspace.librariesModal.getByText("Added")).toBeVisible(); await expect(workspace.page).toHaveScreenshot(); }); From 122d4ebcd38913cb8499122445829489f3eb0269 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 18:14:50 +0000 Subject: [PATCH 100/325] :fire: Remove unused requires flagged by the Clojure linter The dashboard required app.common.exceptions without ever using it, and the plugin shape namespace still referred to token-attr-plugin->token-attr after the per-side stroke width work replaced its only use with expand-token-attrs. Both are leftovers of earlier refactors; dropping them leaves clj-kondo with no warnings. AI-assisted-by: deepseek-v4.1-flash --- frontend/src/app/main/ui/dashboard.cljs | 1 - frontend/src/app/plugins/shape.cljs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 0f53bd0a96..c83a33e97d 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.exceptions :as ex] [app.config :as cf] [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index b34b329598..9f8f7fbda7 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -66,7 +66,7 @@ [app.plugins.strokes :as strokes] [app.plugins.system-events :as se] [app.plugins.text :as text] - [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr-plugin->token-attr token-attr? valid-token-resolution?]] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr? valid-token-resolution?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] From a776050bb6efaaf7d05c73d241c4962eec7dee90 Mon Sep 17 00:00:00 2001 From: Stas Haas Date: Thu, 15 Oct 2026 12:14:08 +0200 Subject: [PATCH 101/325] :globe_with_meridians: Add translations for: German Currently translated at 77.0% (1753 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/ --- frontend/translations/de.po | 116 ++++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/frontend/translations/de.po b/frontend/translations/de.po index a708d53e42..93c0b034cf 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-28 08:13+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: German " -"\n" +"PO-Revision-Date: 2026-10-16 10:51+0000\n" +"Last-Translator: Stas Haas \n" +"Language-Team: German \n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 2026.10.dev0\n" +"X-Generator: Weblate 2026.11.dev0\n" #: src/app/main/ui/auth/register.cljs:227, src/app/main/ui/static.cljs:191, src/app/main/ui/viewer/login.cljs:119 msgid "auth.already-have-account" @@ -7230,3 +7230,109 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Automatisch gespeicherte Versionen werden für %s Tage aufbewahrt." + +#: src/app/main/ui/auth/verify_token.cljs:81 +msgid "auth.notifications.organization-invitation-accepted" +msgstr "Sie gehören jetzt zu %s" + +#: src/app/main/data/nitrate.cljs:602 +msgid "dashboard.change-organization-modal.accept" +msgstr "TEAM VERSCHIEBEN" + +#: src/app/main/data/nitrate.cljs:599 +msgid "dashboard.change-organization-modal.description" +msgstr "" +"Projekte und Dateien bleiben für die Teammitglieder verfügbar. Das Team " +"übernimmt die Einstellungen der neuen Organisation." + +#: src/app/main/ui/dashboard/check_updates.cljs:334 +msgid "dashboard.check-updates.available-message" +msgstr "" +"Neuere Versionen bringen Leistungsverbesserungen, neue Funktionen und " +"Korrekturen, damit Ihre Installation aktuell bleibt." + +#: src/app/main/ui/dashboard/check_updates.cljs:230 +msgid "dashboard.check-updates.unable-hint" +msgstr "" +"Überprüfen Sie Ihr Netzwerk oder die Instanzkonfiguration und versuchen Sie " +"es erneut." + +#: src/app/main/ui/dashboard/check_updates.cljs:265 +msgid "dashboard.check-updates.uptodate-message" +msgstr "Sie verwenden die neueste stabile Version:" + +#: src/app/main/ui/dashboard/check_updates.cljs:254 +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT IST AUF DEM NEUESTEN STAND" + +#: src/app/main/ui/dashboard/check_updates.cljs:374 +msgid "dashboard.check-updates.view-release-notes" +msgstr "Versionshinweise anzeigen" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:240 +msgid "dashboard.go-to-admin-console" +msgstr "Zur Admin-Konsole" + +#: src/app/main/ui/dashboard/import.cljs:597 +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 Bibliothek wurde automatisch anhand des Namens verknüpft." +msgstr[1] "%s Bibliotheken wurden automatisch anhand des Namens verknüpft." + +#: src/app/main/ui/dashboard/import.cljs:733 +msgid "dashboard.import.confirm-library-links" +msgstr "Bibliotheksverknüpfungen bestätigen" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Dateiname" + +#: src/app/main/ui/dashboard/import.cljs:626 +msgid "dashboard.import.import-error.unknown-error" +msgstr "Beim Verarbeiten dieser Datei ist ein Fehler aufgetreten." + +#: src/app/main/ui/dashboard/import.cljs:443 +msgid "dashboard.import.resolve-libraries" +msgstr "" +"Einige Bibliotheken konnten nicht automatisch verknüpft werden. Wählen Sie " +"für jede die richtige Bibliothek:" + +#: src/app/main/ui/dashboard/import.cljs:565 +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Prüfen Sie die Bibliotheksverknüpfungen, bevor Sie bestätigen:" + +#: src/app/main/ui/dashboard/import.cljs:521 +msgid "dashboard.import.summary.linked" +msgstr "Automatisch verknüpft" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "Neu" + +#: src/app/main/ui/dashboard/import.cljs:558 +msgid "dashboard.import.summary.no-selection" +msgstr "Keine Bibliothek ausgewählt" + +#: src/app/main/ui/dashboard/team.cljs:861, src/app/main/ui/dashboard/team.cljs:1202, src/app/main/ui/dashboard/team_form.cljs:156 +msgid "dashboard.invitations.no-permission" +msgstr "" +"Sie haben keine Berechtigung, Personen in dieses Team einzuladen oder " +"Einladungen zu bearbeiten oder zu löschen." + +#: src/app/main/ui/dashboard/team.cljs:152 +msgid "dashboard.invite-profile-disabled" +msgstr "Sie haben keine Berechtigung, Personen in dieses Team einzuladen" + +#: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266 +msgid "dashboard.leave-organization" +msgstr "Organisation verlassen" + +#: src/app/main/data/nitrate.cljs:259 +msgid "dashboard.leave-organization.toast" +msgstr "Sie gehören nicht mehr zur Organisation %s." + +#: src/app/main/ui/dashboard/team_form.cljs:158 +msgid "dashboard.no-organization-allows-create-team.message" +msgstr "" +"Sie haben keine Berechtigung, Teams zu einer Ihrer Organisationen " +"hinzuzufügen." From 53f83c98cfdb7d852b73c6453381d4086509f6fe Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:01:55 +0200 Subject: [PATCH 102/325] :arrow_up: Update GitHub Actions (#12401) - actions/checkout v7 (.github/workflows/commit-checker.yml) - actions/download-artifact v8.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) - actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .github/workflows/commit-checker.yml | 2 +- .github/workflows/tests-e2e.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 0de7fe8975..15790e72a9 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -56,7 +56,7 @@ jobs: # always the trusted version: PR commits are only read, never # executed. On push events this resolves to the pushed sha. - name: Checkout trusted code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.sha || github.sha }} diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 84db8d144d..167f00ddf8 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -206,7 +206,7 @@ jobs: --reporter=blob - name: Upload blob report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-blob-report-${{ matrix.shard }} @@ -215,7 +215,7 @@ jobs: retention-days: 3 - name: Upload test result - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-tests-result-${{ matrix.shard }} @@ -338,7 +338,7 @@ jobs: pnpm install --frozen-lockfile; - name: Download blob reports - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: frontend/all-blob-reports pattern: integration-blob-report-* @@ -367,7 +367,7 @@ jobs: >> "$GITHUB_STEP_SUMMARY" - name: Upload JSON report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-json-report @@ -377,7 +377,7 @@ jobs: retention-days: 7 - name: Upload HTML report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: integration-html-report From 1a68928aca1ac323158222bc2867da033431b662 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:06:09 +0200 Subject: [PATCH 103/325] :arrow_up: Update DHI base images (#12399) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.7-alpine3.24@6d3811f (docker/images/Dockerfile.storybook) - dhi.io/nginx 1.31.7-debian13-dev@10c0037 (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.0@ (docker/devenv/Dockerfile) - dhi.io/node 24.22.0@ (.nvmrc) - dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.0-debian13@c6ddacf (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.exporter) --- .nvmrc | 2 +- docker/devenv/Dockerfile | 8 ++++---- docker/imagemagick/Dockerfile | 2 +- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- docker/images/Dockerfile.storybook | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.nvmrc b/.nvmrc index 166aae1151..cdd6de7dff 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.21.0 +v24.22.0 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index d8ea87ac5d..2a2d2284a9 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.21.0 \ +ENV NODE_VERSION=v24.22.0 \ PNPM_VERSION=12.9.1 \ PATH=/opt/node/bin:$PATH @@ -76,14 +76,14 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - NODE_SHA256='724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5'; \ + NODE_SHA256='b7e7991d5407815adfa74a2a9cd4db683552c8ce32ff4a295c22e972849c3f5d'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='468294b77633d29889c6e4a79cefb1277eba30cd62e7d6a754218cb85a1d730d'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - NODE_SHA256='6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff'; \ + NODE_SHA256='28ea0a438de9a13bd7891fd758eee6c7819ee8af5442dfd8d45bd65e56d4228c'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='b4f58449ac02d9d24023ac89e17e05e8331f8ddc12ca1f489cf231bc2bb2bce1'; \ ;; \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index bfb5544c7d..a0323a1c63 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 4937b04cda..3d74f8be43 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:61dc022a1908439c478ed4b00e883fe8a7a03540fac23a8aed61352e28e6ae07 AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 03d468e72e..34abe54df6 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 6e913f5919..8213d88490 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-debian13-dev@sha256:32e434084ff7e82629abc090c534fa0b02ebf1a7465f8029314bf9e95349c3a1 +FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:10c0037149e51fec759fb6006d5ab8bc8343f0d718c86841bd510848cc5eeef4 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index d175718177..ca65a69340 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 AS build +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.21.0-debian13@sha256:f3575cf1e68ffe163703b691c465af833fd2d94cccc1e825d052c943018f2aa3 AS image +FROM dhi.io/node:24.22.0-debian13@sha256:c6ddacf90691f33734423b06682a63b27abeeafee294830455e91c79614ece8b AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index 20831a6798..ead97142c7 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.21.0-debian13-dev@sha256:2e51e8397b85c3771c554834fb55d7fd5825e7d57a993e00153db42c584b7e58 +FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 13a887fb0a..2b259ffb61 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.6-alpine3.24@sha256:94652754aa6bbf187859b24c0b547aa2c3d07e99d4462868f8d9f36bf2039d88 +FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From fcbc4f17695d6a81892465e0f9588c83b6f658c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 12:53:13 +0000 Subject: [PATCH 104/325] :bug: Remove duplicate declarations in the typography sample `.typography-sample-input` declared `font-size` and `line-height` twice with the same values, so stylelint failed the frontend lint with `declaration-block-no-duplicate-properties`. The duplication came from an earlier staging into develop merge: one side moved the pair next to its comment, the other kept it below `block-size`, and the merge kept both. Drop the second pair. No behaviour change: both pairs held the same values, and only the last declaration of a block takes effect. AI-assisted-by: deepseek-v4.1-flash --- .../app/main/ui/workspace/sidebar/options/menus/typography.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 40e0374813..b9354a9f10 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -128,9 +128,7 @@ $font-preview-box-height: 28px; flex-shrink: 0; min-inline-size: $sz-24; block-size: 100%; - font-size: px2rem(16); white-space: nowrap; - line-height: 1; overflow: hidden; color: var(--color-foreground-primary); } From 49aeeb053b978f29529e3aabd71a8faf007c3616 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sat, 17 Oct 2026 21:53:29 +0000 Subject: [PATCH 105/325] :bug: Use multipart helper in jobs upload-chunk test The multipart-only upload guard rejects the :content map sent by upload-chunked! over plain command!. Send it through multipart-command! like the other upload tests; forged-path tests stay on plain command!. AI-assisted-by: muse-spark-1.3-contributor --- .../backend_tests/rpc/commands/jobs_test.clj | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/test/backend_tests/rpc/commands/jobs_test.clj b/backend/test/backend_tests/rpc/commands/jobs_test.clj index a39f92d71e..5ced5072ee 100644 --- a/backend/test/backend_tests/rpc/commands/jobs_test.clj +++ b/backend/test/backend_tests/rpc/commands/jobs_test.clj @@ -249,17 +249,17 @@ ::rpc/profile-id (:id profile) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id profile) - :session-id session-id - :index 0 - :content (if (some? path) - (let [size (.length (jio/file path))] - {:filename "package.penpot" - :path path - :mtype "application/zip" - :size size}) - (upload-chunk :content content))})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id profile) + :session-id session-id + :index 0 + :content (if (some? path) + (let [size (.length (jio/file path))] + {:filename "package.penpot" + :path path + :mtype "application/zip" + :size size}) + (upload-chunk :content content))})] (t/is (nil? (:error out))) session-id)) From addc5bc31e412b38f756d0714811e8c43807a954 Mon Sep 17 00:00:00 2001 From: "Rafael X." Date: Sun, 18 Oct 2026 22:29:18 +0200 Subject: [PATCH 106/325] :books: Fix typo in incomplete user info hint (#12413) Signed-off-by: Rafael X. --- backend/src/app/auth/oidc.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 1cea8af046..05121e7479 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -629,7 +629,7 @@ (l/warn :hint "received incomplete user info object (please set correct scopes)" :info info) (ex/raise :type :internal :code :incomplete-user-info - :hint "inconmplete user info" + :hint "incomplete user info" :info info))) ;; If the provider is OIDC, we can proceed to check From 8787937b7cd7cf8c7c1ab3a09f79b431de190b04 Mon Sep 17 00:00:00 2001 From: "Stephen Sagarino Jr." <113438801+ReidoBoss@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:24:11 +0800 Subject: [PATCH 107/325] :bug: Truncate long labels in the shadow options panel (#12414) Long translations of "Blur" and "Spread" ran over the number input in the advanced shadow options. The label inherited display: flex from the legacy %input-label placeholder, where text-overflow has no effect, and it could not shrink below its text. Make the label a block with the DS text-ellipsis mixin, size it to its text, and cap it at the field width minus room for the number. Labels that fit look the same as before; longer ones end in an ellipsis and keep their tooltip. Closes #4456 --- .../ui/workspace/sidebar/options/rows/shadow_row.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss index a376138e7f..4e0eff8a0b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss @@ -8,6 +8,7 @@ @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; @use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; @@ -84,8 +85,13 @@ @include t.use-typography("body-small"); .shadow-advanced-label { + @include text-ellipsis; + padding-inline-start: var(--sp-s); - inline-size: px2rem(60); + flex-shrink: 0; + inline-size: auto; + max-inline-size: calc(100% - #{px2rem(36)}); + line-height: $sz-32; } } From 18ca25518e61a1c59d1bc46baaa80cd6f1cbf544 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 14:28:03 +0200 Subject: [PATCH 108/325] :recycle: Use a DS icon button to close the nudge amount modal (#12439) * :recycle: Use a DS icon button to close the nudge amount modal The close button of the nudge amount modal (main menu, Preferences) was a raw button with a deprecated icon and no accessible name. Replace it with a DS ghost icon button labeled "Close", shown in its DS tooltip. The class that places it in the corner goes on the tooltip wrapper, so the tooltip anchors to the button. The numeric inputs stay for the inputs group. Add a visual test that opens the modal from the main menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move nudge modal styles off the deprecated styles The close button is a DS icon button, so its placeholder goes away; it is now 32px wide instead of 28px, and sits 4px from the edge instead of 6px so the icon stays in the same place. Spacing, sizes and the color aliases move to DS tokens and logical properties, the flex mixin is inlined, and the label rule targets its class instead of the label element. The title matches the DS headline-medium typography exactly. The content and labels use body-large and the wrapper body-small, whose line-heights differ by 0.1 from the legacy ones; the labels keep their 1.5 override, so nothing visible changes. refactor/ stays only for the shared modal and labeled input placeholders. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 19 ++++++++++ frontend/src/app/main/ui/workspace/nudge.cljs | 10 ++++-- frontend/src/app/main/ui/workspace/nudge.scss | 36 +++++++++++-------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index f074bc20a2..e67e97ce97 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -219,6 +219,25 @@ test.describe("Palette", () => { }); }); +test.describe("Nudge modal", () => { + test("Shows the nudge amount modal", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.locator("#file-menu-preferences").click(); + await page.locator("#file-menu-nudge").click(); + + await expect(page.getByText("Nudge amount")).toBeVisible(); + await page.mouse.move(0, 0); + await expect(workspace.page).toHaveScreenshot("nudge-modal.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Selected colors", () => { test("Shows the more colors button", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/nudge.cljs b/frontend/src/app/main/ui/workspace/nudge.cljs index 7c2ee4f01c..fb44f4435d 100644 --- a/frontend/src/app/main/ui/workspace/nudge.cljs +++ b/frontend/src/app/main/ui/workspace/nudge.cljs @@ -12,7 +12,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.numeric-input :as deprecated-input] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] @@ -44,8 +45,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} (tr "modals.nudge-title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :input-wrapper)} [:label {:class (stl/css :modal-msg) diff --git a/frontend/src/app/main/ui/workspace/nudge.scss b/frontend/src/app/main/ui/workspace/nudge.scss index 892de2576d..2fe8b7739a 100644 --- a/frontend/src/app/main/ui/workspace/nudge.scss +++ b/frontend/src/app/main/ui/workspace/nudge.scss @@ -4,7 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared modal and labeled input placeholders, which move to +// the DS with the modals and the inputs. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -13,45 +17,47 @@ .modal-container { @extend %modal-container-base; - min-width: deprecated.$s-408; + min-inline-size: px2rem(408); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.headline-medium-typography; + @include use-typography("headline-medium"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.flex-column; + @include use-typography("body-large"); - gap: deprecated.$s-24; - - @include deprecated.body-large-typography; - - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .input-wrapper { @extend %input-with-label; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - label { + // Nested so it wins over the label rule of %input-with-label. + .modal-msg { text-transform: none; } } .modal-msg { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); line-height: 1.5; } From c345c7cbaa20643e7a718b3a01efd58c44bea7d4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:35:44 +0200 Subject: [PATCH 109/325] :recycle: Use a DS icon button to remove imported SVG attributes (#12382) * :recycle: Use a DS icon button to remove imported SVG attributes Replace the raw remove button of each imported SVG attribute with the DS ghost icon button and the DS remove icon. The button had no title, so it gets a new "Remove attribute" label, which the DS tooltip shows. Drop the wrapper div that only held the button. Add a visual test that selects a group imported from SVG so the section shows. AI-assisted-by: claude-opus-5-5 * :recycle: Move imported SVG attributes styles to DS tokens The remove button is a DS icon button now, so .attr-actions and .attr-action-btn with their button placeholders go away. Inline the flex and ellipsis mixins, move spacing to DS tokens and logical properties, sizes to px2rem and the title color alias to its DS token. Use the DS body-small typography; its line-height is 1.3 instead of 1.4. Drop the line clamp rules of the attribute name: its later display: inline-block already overrode the -webkit-box they need. The refactor/ import stays only for %input-element on the value input, which moves with the inputs migration. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 26 +++++++++ .../sidebar/options/menus/svg_attrs.cljs | 11 ++-- .../sidebar/options/menus/svg_attrs.scss | 58 ++++++++----------- frontend/translations/en.po | 4 ++ frontend/translations/es.po | 4 ++ 5 files changed, 64 insertions(+), 39 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index e67e97ce97..e3cf6f1358 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -261,3 +261,29 @@ test.describe("Selected colors", () => { }); }); }); + +test.describe("Imported SVG attributes", () => { + test("Shows the attributes of an imported SVG shape", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9930.json"); + await workspace.goToWorkspace({ + fileId: "8f843b59-7fbb-81ce-8005-aa6d47ae3111", + pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c", + }); + + // The fourth "workspace" board holds the imported SVG groups. + await workspace.clickToggableLayer("workspace", {}, 3); + await workspace.clickToggableLayer("Canvas"); + await workspace.clickLeafLayer("Rectangle 760 (Stroke)"); + + const section = workspace.rightSidebar.getByText("Imported SVG Attributes"); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + // The attributes are a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs index ad07f27f6d..3dbd22eabb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs @@ -11,7 +11,8 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.store :as st] [app.main.ui.components.title-bar :refer [title-bar*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.functions :as uf] [app.util.i18n :refer [tr]] @@ -48,10 +49,10 @@ [:div {:class (stl/css :attr-input)} [:input {:value @last-value :on-change handle-change}]] - [:div {:class (stl/css :attr-actions)} - [:button {:class (stl/css :attr-action-btn) - :on-click handle-delete} - deprecated-icon/remove-icon]]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.sidebar.options.svg-attrs.remove") + :on-click handle-delete + :icon i/remove}]] [:div {:class (stl/css :attr-nested-content)} [:div {:class (stl/css :attr-title)} (str (d/name (last attr)))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss index ed73954e38..949590ec94 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss @@ -4,77 +4,67 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element: inputs move to the DS in their own plan. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .element-set { margin: 0; } .title-spacing-svg-attrs { - padding-left: deprecated.$s-2; + padding-inline-start: var(--sp-xxs); margin: 0; } .element-set-content { - @include deprecated.flex-column; - - margin: deprecated.$s-4 0 0 0; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-start: var(--sp-xs); } .attr-content { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .attr-name { - @include deprecated.body-small-typography; - @include deprecated.two-line-text-ellipsis; + @include use-typography("body-small"); - width: deprecated.$s-88; - margin: auto deprecated.$s-4; - margin-right: 0; display: inline-block; - color: var(--title-foreground-color); + overflow: hidden; + text-overflow: ellipsis; + inline-size: px2rem(88); + max-inline-size: 99%; + margin-block: auto; + margin-inline: var(--sp-xs) 0; + color: var(--color-foreground-secondary); } .attr-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: deprecated.$s-124; -} - -.attr-actions { - display: flex; - gap: deprecated.$s-4; -} - -.attr-action-btn { - @extend %button-tertiary; - - width: deprecated.$s-28; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - } + inline-size: px2rem(124); } .attr-nested-content { display: grid; - row-gap: deprecated.$s-4; + row-gap: var(--sp-xs); } .attr-title { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - font-size: deprecated.$fs-10; + font-size: px2rem(10); text-transform: uppercase; - margin-inline-start: deprecated.$s-4; + margin-inline-start: var(--sp-xs); color: var(--color-foreground-primary); } .attr-row { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f5cfa817f4..a735f09beb 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8696,6 +8696,10 @@ msgstr "Shapes" msgid "workspace.sidebar.layers.texts" msgstr "Texts" +#: src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:53 +msgid "workspace.sidebar.options.svg-attrs.remove" +msgstr "Remove attribute" + #: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:113 msgid "workspace.sidebar.options.svg-attrs.title" msgstr "Imported SVG Attributes" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4ef52e1afe..6cc8b78b9d 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8787,6 +8787,10 @@ msgstr "Formas" msgid "workspace.sidebar.layers.texts" msgstr "Textos" +#: src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:53 +msgid "workspace.sidebar.options.svg-attrs.remove" +msgstr "Eliminar atributo" + #: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:113 msgid "workspace.sidebar.options.svg-attrs.title" msgstr "Atributos del SVG Importado" From 398678e54c39b7b69ef151cad416d35dea0442fa Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:48:43 +0200 Subject: [PATCH 110/325] :recycle: Use a DS icon button for the layout element margin mode (#12423) * :recycle: Use a DS icon button for the layout element margin mode The button that switches a flex or grid element between simple and multiple margins was a raw button with a deprecated icon. Replace it with a DS ghost icon button that shows the multiple state through aria-pressed. Its title moves to the label, shown in the DS tooltip above and to the left since the button sits at the right edge of the sidebar. The grid column class goes on the tooltip wrapper. The legacy margin icons label the numeric inputs and stay for the numeric inputs group; the radio buttons are out of scope. Add a visual test that selects a flex element and shows both margin modes. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from layout element options The margin mode button is now a DS icon button, so its tertiary button, icon and selected placeholders and the deprecated 32px height go away. The class only places the tooltip wrapper in the third grid column. The refactor/ import stays for %input-element, still used by the legacy numeric inputs. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 44 +++++++++++++++++++ .../sidebar/options/menus/layout_item.cljs | 14 +++--- .../sidebar/options/menus/layout_item.scss | 11 ----- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index e3cf6f1358..6eba9b5bd5 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -133,6 +133,50 @@ test.describe("Design tab", () => { threshold: 0, }); }); + + test("Shows the margin modes of a flex layout element", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-7760.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-7760.json", + ); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "cd90e028-326a-80b4-8004-7cdec16ffad5", + pageId: "cd90e028-326a-80b4-8004-7cdec16ffad6", + }); + + await workspace.clickToggableLayer("Flex Board"); + await workspace.clickLeafLayer("Rectangle"); + + const section = workspace.rightSidebar.getByRole("region", { + name: "Layout item section", + }); + const marginMode = section.getByRole("button", { + name: "Margin - multiple", + }); + await expect(marginMode).toBeVisible(); + // The margin button is a small part of the section: compare every pixel. + await expect(section).toHaveScreenshot("margin-simple.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await marginMode.click(); + await marginMode.blur(); + await page.mouse.move(0, 0); + await expect(section.getByRole("textbox", { name: "Top margin" })) + .toBeVisible(); + await expect(section).toHaveScreenshot("margin-multiple.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); }); test.describe("Assets tab", () => { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index 659aea2fb3..28443e1e7e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -19,6 +19,7 @@ [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.sidebar.options.common :as soc] @@ -408,12 +409,13 @@ (= type :multiple) [:> margin-multiple* props])] - [:button {:class (stl/css-case - :margin-mode true - :selected (= type :multiple)) - :title "Margin - multiple" - :on-click on-type-change'} - deprecated-icon/margin]])) + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :margin-mode) + :tooltip-placement "top-left" + :aria-pressed (= type :multiple) + :aria-label "Margin - multiple" + :icon i/margin + :on-click on-type-change'}]])) (mf/defc element-behaviour-horizontal {::mf/props :obj diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss index 52b6a165e3..914bc85ffc 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss @@ -78,18 +78,7 @@ } .margin-mode { - @extend %button-tertiary; - grid-column: 3; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - } - - &.selected { - @extend %button-icon-selected; - } } .margin-simple { From 3d6823eacd957ff838f1452f7dc6ca6aab54412b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:48:59 +0200 Subject: [PATCH 111/325] :recycle: Use DS icon buttons for the flex wrap and padding mode (#12428) * :recycle: Use DS icon buttons for the flex wrap and padding mode The wrap button of a flex layout and the button that shows the four padding sides (flex and grid layouts) were raw buttons with deprecated icons. Replace them with DS ghost icon buttons that show their state through aria-pressed, with the tooltip above and to the left since they sit at the right edge of the sidebar. The wrap label is now always "Wrap": its title switched to "No wrap" while pressed, which conflicts with the pressed state. Add a visual test that selects a flex board and shows both buttons at rest and pressed. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated wrap and padding toggle styles The wrap and padding mode buttons are now DS icon buttons, so their tertiary button, icon and selected placeholders go away. The tooltip wrappers take the same grid cells the buttons had. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 47 +++++++++++++++++++ .../options/menus/layout_container.cljs | 28 +++++------ .../options/menus/layout_container.scss | 38 --------------- 3 files changed, 60 insertions(+), 53 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 6eba9b5bd5..a9be81bf91 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -110,6 +110,53 @@ test.describe("Design tab", () => { await expect(workspace.page).toHaveScreenshot(); }); + test("Shows the wrap and padding modes of a flex layout", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-7760.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-7760.json", + ); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "cd90e028-326a-80b4-8004-7cdec16ffad5", + pageId: "cd90e028-326a-80b4-8004-7cdec16ffad6", + }); + + await workspace.clickLeafLayer("Flex Board"); + + const section = workspace.rightSidebar.getByRole("region", { + name: "Layout container section", + }); + const wrap = section.getByRole("button", { name: /^(No w|W)rap$/ }); + const padding = section.getByRole("button", { + name: "Show 4 sided padding options", + }); + await expect(wrap).toBeVisible(); + // The buttons are a small part of the section: compare every pixel. + await expect(section).toHaveScreenshot("flex-layout-rest.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await wrap.click(); + await padding.click(); + await padding.blur(); + await page.mouse.move(0, 0); + await expect(section.getByRole("textbox", { name: /top padding/i })) + .toBeVisible(); + await expect(section).toHaveScreenshot("flex-layout-pressed.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + test("Shows the align options when selecting two shapes", async ({ page, }) => { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 4fb0ebde64..5e043118d2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -192,13 +192,12 @@ (mf/defc wrap-row {::mf/props :obj} [{:keys [wrap-type on-click]}] - [:button {:class (stl/css-case :wrap-button true - :selected (= wrap-type :wrap)) - :title (if (= :wrap wrap-type) - "No wrap" - "Wrap") - :on-click on-click} - deprecated-icon/wrap]) + [:> icon-button* {:variant "ghost" + :tooltip-placement "top-left" + :aria-pressed (= wrap-type :wrap) + :aria-label "Wrap" + :icon i/wrap + :on-click on-click}]) (mf/defc align-row {::mf/props :obj} @@ -662,14 +661,13 @@ (= type :multiple) [:> multiple-padding-selection* props])] - [:button {:class (stl/css-case - :padding-toggle true - :selected (= type :multiple)) - :title (tr "workspace.layout-grid.editor.padding.expand") - :aria-label (tr "workspace.layout-grid.editor.padding.expand") - :data-type (d/name type) - :on-click on-type-change'} - deprecated-icon/padding-extended]])) + [:> icon-button* {:variant "ghost" + :tooltip-placement "top-left" + :aria-pressed (= type :multiple) + :aria-label (tr "workspace.layout-grid.editor.padding.expand") + :data-type (d/name type) + :icon i/padding-extended + :on-click on-type-change'}]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; GAP diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 7182dc5729..123efabf29 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -61,25 +61,6 @@ grid-column: span 4; } -// TODO: Replace this buttons with DS buttons -.wrap-button { - @extend %button-tertiary; - - border-radius: $br-8; - block-size: $sz-32; - inline-size: $sz-32; - - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - } - - &.selected { - @extend %button-icon-selected; - } -} - .second-row, .third-row { grid-column: 1 / -1; @@ -155,25 +136,6 @@ gap: var(--sp-xs); } -// TODO: Replace this buttons with DS buttons -.padding-toggle { - @extend %button-tertiary; - - block-size: $sz-32; - inline-size: $sz-32; - border-radius: $br-8; - - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - } - - &.selected { - @extend %button-icon-selected; - } -} - .grid-layout-menu { @include sidebar.option-grid-structure; } From e4da0527fcfd6b0784a34e038bd796a640dc56e6 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:49:14 +0200 Subject: [PATCH 112/325] :recycle: Use DS buttons and icons in the grid layout options (#12431) * :recycle: Use DS buttons and icons in the grid layout options The "Edit grid" button of a grid layout and the "Exit" button of the grid editor were raw buttons with the deprecated secondary style. Replace them with DS secondary buttons; their text and actions stay the same. Drop the "alt" attribute of "Edit grid": it is not valid on a button. The expand and add buttons of the column and row headers are 52px parts of a custom header, not DS buttons, so they stay raw; only their deprecated icons move to the DS icon, as does the direction icon of each track. Add a visual test that shows the grid layout options and the grid editor with the column tracks expanded. AI-assisted-by: claude-opus-5-5 * :recycle: Remove deprecated button styles from the grid layout options "Edit grid" and "Exit" are now DS buttons, so their secondary placeholder and typography go away; the classes only place them in the grid and center their label. The track header and direction icons drop the deprecated icon placeholder and get their colors through classes. The header buttons keep their secondary and tertiary placeholders, since they stay raw. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 38 +++++++++++++ .../options/menus/layout_container.cljs | 24 ++++---- .../options/menus/layout_container.scss | 55 +++++-------------- 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index a9be81bf91..7ae69e0520 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -53,6 +53,44 @@ test.describe("Viewport", () => { }); }); +test.describe("Grid layout", () => { + test("Shows the grid layout options and the grid editor", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9042.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-9042.json", + ); + await workspace.goToWorkspace({ + fileId: "af2494d0-39ba-8184-8005-230696f6df5c", + pageId: "af2494d0-39ba-8184-8005-230696f6df5d", + }); + + await workspace.clickLeafLayer("Board"); + const section = page.getByTestId("inspect-layout"); + await expect(section.getByRole("button", { name: "Edit grid" })) + .toBeVisible(); + // The buttons are a small part of the section: compare every pixel. + await expect(section).toHaveScreenshot("grid-layout-options.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await section.getByRole("button", { name: "Edit grid" }).click(); + await expect(workspace.rightSidebar.getByText("GRID LAYOUT")).toBeVisible(); + // Expand the column tracks so their rows show too. + await workspace.rightSidebar.getByText(/^Columns? \d/).click(); + await page.mouse.move(0, 0); + await expect(workspace.rightSidebar).toHaveScreenshot( + "grid-layout-editor.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + }); +}); + test.describe("Design tab", () => { test("Shows the boolean options when selecting two shapes", async ({ page, diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 5e043118d2..f8dcee251d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -25,8 +25,9 @@ [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.select :refer [select]] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] [app.main.ui.icons :as deprecated-icon] @@ -845,10 +846,9 @@ (if-not active? (st/emit! (udw/start-edition-mode id)) (st/emit! :interrupt))))] - [:button - {:class (stl/css :edit-mode-btn) - :alt "Grid edit mode" - :on-click toggle-edit-mode} + [:> button* {:variant "secondary" + :class (stl/css :edit-mode-btn) + :on-click toggle-edit-mode} (tr "workspace.layout-grid.editor.options.edit-grid")])) (mf/defc align-grid-row @@ -988,7 +988,8 @@ [:div {:class (stl/css :track-info-container)} [:div {:class (stl/css :track-info-dir-icon) :on-click handle-select-track} - (if is-column deprecated-icon/flex-vertical deprecated-icon/flex-horizontal)] + [:> icon* {:icon-id (if is-column i/flex-vertical i/flex-horizontal) + :class (stl/css :track-info-dir)}]] [:div {:class (stl/css :track-info-value)} [:> deprecated-input/numeric-input* {:no-validate true @@ -1036,11 +1037,13 @@ [:div {:class (stl/css :grid-tracks) :data-testid testid} [:div {:class (stl/css :grid-track-header)} - [:button {:class (stl/css :expand-icon) :on-click toggle} deprecated-icon/menu] + [:button {:class (stl/css :expand-icon) :on-click toggle} + [:> icon* {:icon-id i/menu :class (stl/css :expand-icon-svg)}]] [:div {:class (stl/css :track-title) :on-click toggle} [:div {:class (stl/css :track-name) :title track-name} track-name] [:div {:class (stl/css :track-detail) :title track-detail} track-detail]] - [:button {:class (stl/css :add-column) :on-click add-track} deprecated-icon/add]] + [:button {:class (stl/css :add-column) :on-click add-track} + [:> icon* {:icon-id i/add :size "s" :class (stl/css :add-column-svg)}]]] (when expanded? [:> h/sortable-container* {} @@ -1568,8 +1571,9 @@ :aria-label (tr "labels.help-center") :on-click open-grid-help :icon i/help}] - [:button {:class (stl/css :exit-btn) - :on-click #(st/emit! (udw/clear-edition-mode))} + [:> button* {:variant "secondary" + :class (stl/css :exit-btn) + :on-click #(st/emit! (udw/clear-edition-mode))} (tr "workspace.layout-grid.editor.options.exit")]] [:div {:class (stl/css :row :first-row)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 123efabf29..34f3d81b43 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -176,22 +176,14 @@ grid-column: span 5; } -// TODO: Replace this buttons with DS buttons .edit-mode-btn { - @extend %button-secondary; - @include t.use-typography("headline-small"); - inline-size: 100%; - padding: var(--sp-s); + justify-content: center; grid-column: span 7; } -// TODO: Replace this buttons with DS buttons .exit-btn { - @extend %button-secondary; - @include t.use-typography("headline-small"); - - padding: var(--sp-s) var(--sp-xl); + justify-content: center; grid-column: span 2; } @@ -237,22 +229,17 @@ grid-column: 1 / span 7; } -// TODO: Replace this buttons with DS buttons .track-info-dir-icon { + display: flex; + align-items: center; cursor: pointer; border-radius: $br-8 0 0 $br-8; background-color: var(--color-background-tertiary); padding: 0 var(--sp-s); + color: var(--color-foreground-secondary); - svg { - @extend %button-icon; - - stroke: var(--color-foreground-secondary); - block-size: 100%; - } - - &:hover svg { - stroke: var(--color-foreground-primary); + &:hover { + color: var(--color-foreground-primary); } } @@ -309,7 +296,8 @@ color: var(--color-foreground-secondary); } -// TODO: Replace this buttons with DS buttons +// Raw buttons on purpose: they are 52px segments of the track header, not +// DS buttons. The nested icon rules win over the placeholder svg colors. .expand-icon { @extend %button-secondary; @@ -317,38 +305,23 @@ border-radius: $br-8 0 0 $br-8; border-inline-end: $b-1 solid var(--color-background-primary); - svg { - @extend %button-icon; - + .expand-icon-svg { stroke: var(--color-foreground-secondary); - fill: var(--color-foreground-secondary); } - &:hover, - &:active { - svg { - stroke: var(--color-accent-primary); - fill: var(--color-accent-primary); - } + &:hover .expand-icon-svg, + &:active .expand-icon-svg { + stroke: var(--color-accent-primary); } } -// TODO: Replace this buttons with DS buttons .add-column { @extend %button-tertiary; block-size: px2rem(52); - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - stroke-width: px2rem(1); - block-size: $sz-12; - inline-size: $sz-12; + .add-column-svg { stroke: var(--color-foreground-secondary); - fill: var(--color-foreground-secondary); } } From d6e1cb23e0283d99a9e684f32f9a7f04a4b7f467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 19 Oct 2026 15:49:41 +0200 Subject: [PATCH 113/325] :whale: Serve Penpot and the Admin Console from a subpath (#12358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :whale: Serve the frontend under the path of PENPOT_PUBLIC_URI Requests below the path are rewritten to the root, so a reverse proxy can forward them unchanged. The Admin Console keeps receiving the original path. AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :whale: Strip the public path before proxying to the Admin Console The Admin Console is served at the root of its own server and applies the public path at runtime, so it needs a matching Admin Console image. AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :books: Document subpath deployments in docker-compose.yaml AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :books: Describe subpath deployments without proxy rewrites The frontend now serves the subpath itself, so the reverse proxy must forward it unchanged. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --------- Signed-off-by: David Barragán Merino --- docker/images/docker-compose.yaml | 5 +++++ ...ginx-admin-console-locations.conf.template | 9 +++++++- docker/images/files/nginx-entrypoint.sh | 22 +++++++++++++++++-- docker/images/files/nginx.conf.template | 6 +++-- docs/technical-guide/configuration.md | 11 +++------- .../technical-guide/getting-started/docker.md | 12 +++++----- 6 files changed, 45 insertions(+), 20 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index e667bc3049..4c69760e24 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -26,6 +26,9 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console +## Public URI where Penpot is reached. To serve Penpot from a subpath, include +## it with a trailing slash, e.g. https://example.com/penpot/. A reverse proxy in +## front must forward the subpath unchanged, without rewriting it. x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -110,6 +113,8 @@ services: # ## internet with HTTPS using traefik. # - "traefik.http.routers.penpot-https.rule=Host(``)" + # ## When Penpot is served from a subpath, match it too: + # - "traefik.http.routers.penpot-https.rule=Host(``) && PathPrefix(`/`)" # - "traefik.http.routers.penpot-https.entrypoints=websecure" # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" # - "traefik.http.routers.penpot-https.tls=true" diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index fead3783c9..2b0a4f95b8 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,7 +1,14 @@ +# The Admin Console is served at the root of its own server: strip the public +# path and admin-console/ from the original request URI. location /admin-console { + set $admin_console_uri "/"; + if ($request_uri ~ "^${PENPOT_PUBLIC_PATH_REGEX}admin-console(/.*)$") { + set $admin_console_uri $1; + } + proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$admin_console_uri; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 3ec97403a8..e141db2d3a 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -51,6 +51,24 @@ update_oidc_name /var/www/app/js/config.js ## Nginx Config ######################################### +# Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). +# Requests below it are rewritten to the root, so the proxy in front of this +# server forwards them unchanged and $request_uri keeps the original path. +PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" +PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" +export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" + +# shellcheck disable=SC2016 # literal regex metacharacters +PENPOT_PUBLIC_PATH_REGEX="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" +export PENPOT_PUBLIC_PATH_REGEX + +if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then + export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } + rewrite ^${PENPOT_PUBLIC_PATH_REGEX}(.*)\$ /\$1 last;" +else + export PENPOT_PUBLIC_PATH_DIRECTIVES="" +fi + export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB @@ -58,12 +76,12 @@ export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::] if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE,\$PENPOT_PUBLIC_PATH,\$PENPOT_PUBLIC_PATH_DIRECTIVES" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} - envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ + envsubst "\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_PUBLIC_PATH_REGEX" \ < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf else rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 0451e7a24a..c8800b0503 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -102,6 +102,8 @@ http { root /var/www/app/; + ${PENPOT_PUBLIC_PATH_DIRECTIVES} + location @handle_redirect { set $redirect_uri "$upstream_http_location"; set $redirect_host "$upstream_http_x_host"; @@ -167,7 +169,7 @@ http { location /readyz { access_log off; proxy_set_header Host $proxy_host; - proxy_pass $PENPOT_BACKEND_URI$request_uri; + proxy_pass $PENPOT_BACKEND_URI$uri$is_args$args; } location /ws/notifications { @@ -198,7 +200,7 @@ http { } location ~ ^/[^/]+/(.*)$ { - return 301 " /404"; + return 301 " ${PENPOT_PUBLIC_PATH}404"; } if ($penpot_link_preview_agent) { diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 695a7e3a02..db72a68b0d 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -384,16 +384,11 @@ PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ Your reverse proxy or Ingress must also: -- Route requests under `/penpot/` to the Penpot frontend. -- Remove the `/penpot` prefix before forwarding each request. The frontend - continues to serve its internal routes from `/`. +- Route requests under `/penpot/` to the Penpot frontend without rewriting the + path. The frontend expects to receive the subpath. - Preserve WebSocket upgrade headers for notifications and other services that use WebSockets. -Changing only `PENPOT_PUBLIC_URI` is not enough. Without the matching proxy -rewrite, static assets, API calls, and WebSocket connections will use routes that -the frontend does not recognize. - #### Migrate an existing installation to a subpath Moving an installation from `/` to a subpath does not require a database or @@ -403,7 +398,7 @@ the existing storage. Before changing the public URL: 1. Back up the database, assets, and current deployment configuration. -2. Configure the proxy or Ingress route and prefix rewrite. +2. Configure the proxy or Ingress route for the subpath. 3. Update `PENPOT_PUBLIC_URI` for every Penpot component that uses it. 4. Keep the existing `PENPOT_SECRET_KEY`, database, and asset storage configuration. diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 3e8f9740a8..d6d9a0ae4b 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -325,8 +325,7 @@ First, set the complete public URL in `docker-compose.yaml`: PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ ``` -Then route the subpath to the Penpot frontend and remove the prefix before -forwarding the request: +Then route the subpath to the Penpot frontend, forwarding the path unchanged: ```nginx server { @@ -344,8 +343,6 @@ server { } location /penpot/ { - rewrite ^/penpot/(.*)$ /$1 break; - proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Scheme $scheme; @@ -359,9 +356,10 @@ server { } ``` -This single location also covers the API and WebSocket endpoints because NGINX -forwards them after removing `/penpot`. Keep the trailing slash in -`PENPOT_PUBLIC_URI`. +This single location covers every Penpot endpoint, WebSockets included. Keep +the trailing slash in `PENPOT_PUBLIC_URI`, and do not add a path to `proxy_pass` +(such as `http://localhost:9001/`): NGINX would then replace the `/penpot/` +prefix instead of forwarding it. ### Example with CADDY SERVER From 1c70b0ce2d5d0c3d5f2b1b566dbbdcf8e0c72bc4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 15:52:48 +0200 Subject: [PATCH 114/325] :recycle: Use the DS icon in the workspace left header (#12432) * :recycle: Use the DS icon in the workspace left header The shared library badge of the left header used a deprecated icon; use the DS library icon instead. Drop the deprecated icons inside the save status dot. They rendered as 500px SVGs with no stroke, so they never showed: the dot is only its background color, and its title keeps the status text. The Penpot logo is brand art and stays. Add a visual test that opens a shared file and shows the header with the badge, then the saved status after drawing a shape. AI-assisted-by: claude-opus-5-5 * :recycle: Move workspace left header styles off the deprecated styles Inline the flex and ellipsis mixins, move spacing, sizes and borders to DS tokens and logical properties, and resolve the color aliases to their DS tokens. The status dot takes its color from a local variable. The badge icon gets its color through a class instead of an svg selector. The project name uses the DS headline-small typography: 12px instead of 11px. The file name matches title-small exactly. Left with a TODO: the rename input font size and z-index have no DS match, and the pending status orange (#f5a91b) has no DS color. left_header.scss no longer imports refactor/. AI-assisted-by: claude-opus-5-5 * :recycle: Resolve the TODOs left in the workspace left header styles The status dot used the legacy warning orange #f5a91b, which drifted from the DS one when both replaced #fe4811 a few days apart in early 2025. Use the DS --color-accent-warning (#fe9c07), the orange of every other DS warning, so the dot looks a little more orange while saving. The rename input had z-index 20, which did nothing: the input is neither positioned nor a flex or grid item. Remove it. The rename input only set its font size and got the rest from the global reset; it now uses the DS title-small typography, like the file name. Its line-height goes from normal to 1.2, so it grows from 16 to 16.8px. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 33 +++++ .../app/main/ui/workspace/left_header.cljs | 13 +- .../app/main/ui/workspace/left_header.scss | 134 ++++++++++-------- 3 files changed, 110 insertions(+), 70 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 7ae69e0520..73c5e2a524 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -320,6 +320,39 @@ test.describe("Assets tab", () => { }); }); +test.describe("Left header", () => { + test("Shows the shared badge and the save status", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-library.json"); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + const header = page.locator("header", { hasText: "Testing library 1" }); + await expect(header).toBeVisible(); + // The badge and the status dot are tiny: compare every pixel. + await expect(header).toHaveScreenshot("left-header-shared.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.rectShapeButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + await expect(header.getByTitle("Saved")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot("left-header-saved.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Palette", () => { test("Shows the bottom palette expanded and collapsed", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 37e6247b87..3eec46c77d 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -15,6 +15,7 @@ [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.main-menu :as main-menu] [app.util.dom :as dom] @@ -124,17 +125,11 @@ :saved (tr "workspace.header.saved") :retrying (tr "workspace.header.retrying") :error (tr "workspace.header.save-error") - nil)} - (case persistence-status - :pending deprecated-icon/status-alert - :saving deprecated-icon/status-alert - :saved deprecated-icon/status-tick - :retrying deprecated-icon/status-alert - :error deprecated-icon/status-wrong - nil)] + nil)}] [:div {:class (stl/css :file-name-label)} file-name]])] (when ^boolean shared? - [:span {:class (stl/css :shared-badge)} deprecated-icon/library]) + [:span {:class (stl/css :shared-badge)} + [:> icon* {:icon-id i/library :class (stl/css :shared-badge-icon)}]]) [:div {:class (stl/css :menu-section)} [:> main-menu/menu* {:layout layout :file file}]]])) diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 663c7465b2..a1542d48a9 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -4,27 +4,32 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .workspace-header-left { display: flex; align-items: center; - padding: deprecated.$s-12 deprecated.$s-12 deprecated.$s-8 deprecated.$s-12; - min-height: deprecated.$s-52; + padding: var(--sp-m) var(--sp-m) var(--sp-s) var(--sp-m); + min-block-size: px2rem(52); } .main-icon { - @include deprecated.flex-center; - - width: deprecated.$s-32; - height: deprecated.$s-32; - min-height: deprecated.$s-32; - margin-right: deprecated.$s-4; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-32; + block-size: $sz-32; + min-block-size: $sz-32; + margin-inline-end: var(--sp-xs); + // The Penpot logo is brand art from the legacy icon set and has no class. svg { - min-height: deprecated.$s-32; - width: deprecated.$s-32; - fill: var(--icon-foreground-hover); + min-block-size: $sz-32; + inline-size: $sz-32; + fill: var(--color-foreground-primary); } } @@ -32,49 +37,60 @@ position: relative; flex-grow: 1; flex-shrink: 1; - min-width: 0; - height: deprecated.$s-32; - min-height: deprecated.$s-32; + min-inline-size: 0; + block-size: $sz-32; + min-block-size: $sz-32; } .project-name, .file-name { - @include deprecated.uppercase-title-typography; - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: 100%; - padding-bottom: deprecated.$s-2; - color: var(--title-foreground-color); + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + block-size: $sz-16; + inline-size: 100%; + padding-block-end: var(--sp-xxs); cursor: pointer; } -.file-name { - @include deprecated.small-title-typography; +.project-name { + @include use-typography("headline-small"); - text-transform: none; - color: var(--title-foreground-color-hover); + color: var(--color-foreground-secondary); +} + +.file-name { + @include use-typography("title-small"); + + color: var(--color-foreground-primary); align-items: center; display: flex; flex-direction: row; } .file-name-label { - @include deprecated.text-ellipsis; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .file-name-input { - @include deprecated.flex-center; + @include use-typography("title-small"); - width: 100%; + display: flex; + justify-content: center; + align-items: center; + inline-size: 100%; margin: 0; border: 0; padding: 0; - border-radius: deprecated.$br-4; - background-color: var(--input-background-color); - font-size: deprecated.$fs-14; - color: var(--input-foreground-color); - z-index: deprecated.$z-index-20; + border-radius: $br-4; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); white-space: break-spaces; &:focus { @@ -83,46 +99,42 @@ } .shared-badge { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-16; + block-size: $sz-32; + margin-inline-end: var(--sp-xs); +} - width: deprecated.$s-16; - height: deprecated.$s-32; - margin-right: deprecated.$s-4; - - svg { - stroke: var(--button-secondary-foreground-color-rest); - fill: none; - height: deprecated.$s-16; - width: deprecated.$s-16; - } +.shared-badge-icon { + color: var(--color-foreground-secondary); } .status-notification { - width: deprecated.$s-6; - height: deprecated.$s-6; - border-radius: 50%; - margin-right: deprecated.$s-4; + --status-notification-background-color: transparent; + + inline-size: $sz-6; + block-size: $sz-6; + border-radius: $br-circle; + margin-inline-end: var(--sp-xs); flex-shrink: 0; + background-color: var(--status-notification-background-color); - &.pending-status { - background-color: var(--status-widget-background-color-warning); - } - - &.saving-status { - background-color: var(--status-widget-background-color-pending); + &.pending-status, + &.saving-status, + &.retrying-status { + --status-notification-background-color: var(--color-accent-warning); } &.saved-status { - background-color: var(--status-widget-background-color-success); + --status-notification-background-color: var(--color-accent-success); + animation: jump 0.3s ease-out; } - &.retrying-status { - background-color: var(--status-widget-background-color-warning); - } - &.error-status { - background-color: var(--status-widget-background-color-error); + --status-notification-background-color: var(--color-foreground-error); } } From cdb7dc2977620ef1500bc492d657a6f3e94bcdf5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 19 Oct 2026 17:05:22 +0200 Subject: [PATCH 115/325] :recycle: Use a DS button for edit grid in the grid cell options (#12385) * :recycle: Use a DS button for edit grid in the grid cell options Replace the raw "Edit grid" button of the grid cell options with the DS secondary button. Drop its alt attribute, which is not valid on a button; the label already names it. The column and row icons label the numeric inputs, so they move with the numeric inputs migration. Add a visual test that selects a cell in grid edit mode so the options show. AI-assisted-by: claude-opus-5-5 * :recycle: Move grid cell options styles to DS tokens The edit grid button is a DS button now, so its button placeholder and typography mixin go away; the class only stretches it and centers its label. Merge the two .grid-cell-menu-container rules, inline the flex mixins, and move spacing, borders and color aliases to DS tokens and logical properties. Use the DS body-small typography in the inputs; its line-height is 1.3 instead of 1.4. The refactor/ import stays only for %input-element and %button-icon, used by the inputs and their legacy icons, which move with the numeric inputs migration. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 36 +++++++++++ .../sidebar/options/menus/grid_cell.cljs | 8 +-- .../sidebar/options/menus/grid_cell.scss | 59 ++++++++++--------- 3 files changed, 71 insertions(+), 32 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 73c5e2a524..7d21687ba7 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -449,3 +449,39 @@ test.describe("Imported SVG attributes", () => { }); }); }); + +test.describe("Grid cell options", () => { + test("Shows the grid cell options when selecting a cell", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9042.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-9042.json", + ); + await workspace.goToWorkspace({ + fileId: "af2494d0-39ba-8184-8005-230696f6df5c", + pageId: "af2494d0-39ba-8184-8005-230696f6df5d", + }); + + await workspace.clickLeafLayer("Board"); + await page + .getByTestId("inspect-layout") + .getByRole("button", { name: "Edit grid" }) + .click(); + + // Click the first cell of the grid editor. + const cell = page.locator("g.cell-editor > rect").first(); + const box = await cell.boundingBox(); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + await expect(workspace.rightSidebar.getByText("Grid cell")).toBeVisible(); + // The cell options are a small part of the sidebar: compare every pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs index 2434c66c07..b7ee6b4f6e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs @@ -19,6 +19,7 @@ [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as deprecated-icon] @@ -289,8 +290,7 @@ :set-alignment set-justify-self}]] [:div {:class (stl/css :row)} - [:button - {:class (stl/css :edit-grid-btn) - :alt (tr "workspace.layout-grid.editor.options.edit-grid") - :on-click toggle-edit-mode} + [:> button* {:variant "secondary" + :class (stl/css :edit-grid-btn) + :on-click toggle-edit-mode} (tr "workspace.layout-grid.editor.options.edit-grid")]]])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss index 8c245a491f..6b2092b133 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss @@ -4,14 +4,20 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element and %button-icon: the inputs and their icons move +// to the DS in their own plan. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; .grid-cell-menu-container { - @include deprecated.flex-column; - - margin-top: deprecated.$s-8; - gap: deprecated.$s-16; + display: flex; + flex-direction: column; + gap: var(--sp-l); + grid-column: span 8; + margin-block-start: var(--sp-s); } .grid-cell-menu { @@ -20,55 +26,52 @@ .grid-cell-menu-title { grid-column: span 8; - font-size: deprecated.$fs-11; -} - -.grid-cell-menu-container { - grid-column: span 8; + font-size: px2rem(11); } .row { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .cell-mode :global(label) { - padding: 0 deprecated.$s-12; + padding: 0 var(--sp-m); } .edit-grid-btn { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - width: 100%; - padding: deprecated.$s-8; + justify-content: center; + inline-size: 100%; } .area-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: 100%; - padding: deprecated.$s-8; + inline-size: 100%; + padding: var(--sp-s); } .grid-coord-group { - @include deprecated.flex-row; - - border-radius: deprecated.$br-8; - padding-left: deprecated.$s-4; - background-color: var(--input-background-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + border-radius: $br-8; + padding-inline-start: var(--sp-xs); + background-color: var(--color-background-tertiary); } +// The legacy icon is a raw svg with no class of its own. .icon svg { @extend %button-icon; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-secondary); } .coord-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; - border-left: deprecated.$s-1 solid var(--panel-background-color); + border-radius: 0 $br-8 $br-8 0; + border-inline-start: $b-1 solid var(--color-background-primary); } From ccf99c7dcf35e8ddcbd3f609f1aec89b949be101 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 22:35:58 +0200 Subject: [PATCH 116/325] :recycle: Archive audit log from a pending queue with two jobs (#12373) * :recycle: Archive audit log from a pending queue with two jobs Replace the mark-and-sweep audit archive with a pending queue (audit_log_pending, migration 0157): every row is born pending for the archivers enabled at insert time, in the same transaction, so a rollback takes both. Two jobs share one namespace, one queue each: :audit-log-archive-nexus and :audit-log-archive-nitrate, each with its own closed params schema, deps and every-5m cron entry gated by its send flag. Each job drains its queue oldest-first in 128-row chunks (one transaction per chunk, same total order, no SKIP LOCKED so arrival stays chronological) and deletes its pendings on completion; whoever deletes the last pending deletes the row. Housekeeping lives in both jobs: reap of disabled pendings plus sweep of orphans, both idempotent. A refused chunk raises :archive-chunk-refused and aborts the job; the cron retries. No separate GC task. Metrics with bounded labels only: archived/failed/skipped per destination, discarded per reason (reaped pendings, swept rows: the silent R3 loss, now dashboard-visible). Timing comes from the jobs substrate; queue depth is read off the replica DB. No gauge, no custom histogram. Destination hosts log once at job-def init; per-run logs carry counts plus frozen elapsed (log props evaluate lazily in the logger thread). Pre-existing rows are swept unsent (no backfill, accepted loss); rows born with no archiver on share that fate (R3). Memory mem:audit-log documents the design. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Address review comments on audit log archive jobs Rename the second archive destination from nitrate to admin-console (jobs, metric and log labels, error code), reusing the :admin-console flag. Unify the pending-queue insert into a single helper used by both producers. Stagger the archive crons (Nexus at second 0, admin console at second 30). Add per-call dbg logs to the admin-console send. Update the audit-log memory and the affected tests. Closes #12372 AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/audit-log.md | 46 +- backend/src/app/loggers/audit.clj | 14 +- .../src/app/loggers/audit/archive_task.clj | 536 +++++++++++++--- backend/src/app/loggers/audit/gc_task.clj | 46 -- backend/src/app/main.clj | 78 ++- backend/src/app/migrations.clj | 5 +- .../sql/0157-add-audit-log-pending-table.sql | 39 ++ backend/src/app/rpc/commands/audit.clj | 58 +- backend/src/app/tasks/telemetry.clj | 40 +- .../loggers_audit_archive_task_test.clj | 605 ++++++++++++++---- backend/test/backend_tests/rpc_audit_test.clj | 166 ++++- .../backend_tests/tasks_telemetry_test.clj | 43 ++ 12 files changed, 1288 insertions(+), 388 deletions(-) delete mode 100644 backend/src/app/loggers/audit/gc_task.clj create mode 100644 backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 898e95e413..bc33a4a190 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -12,14 +12,15 @@ The system has: - **Three producers** (they create events): the Penpot backend (RPC layer), the Penpot frontend (browser app), and the Nitrate admin-console, which sends both server-side and client-side events. - **One central buffer**: the Postgres `audit_log` table in Penpot. It is temporary: it holds events for minutes/hours until they are archived, then they are deleted. -- **Four consumers** (they read events): webhooks, error reporters, telemetry, and the external archive in Nexus. -- **One long-term archive**: **Nexus**, a separate service that stores events for the long term. Archiving to **Nitrate** is in development and **does not exist on the current branch** (see section 11). +- **Four consumers** (they read events): webhooks, error reporters, telemetry, and the external archives in Nexus and in the admin console. +- **Two long-term archives**: **Nexus**, a separate service that stores events for the long term, and the **admin console** (Nitrate), which ingests its own allowlisted events through a second archive job (see section 12). ``` Penpot frontend ─ POST push-audit-events (public) ─────────▶┐ Nitrate admin-console (browser) ─ POST push-audit-events ──▶│ -Nitrate admin-console (server) ─ POST push-audit-events ──▶│ ─▶ audit_log table (buffer) ── cron every 5m ──▶ Nexus (archive) -Penpot backend (RPC wrap-audit) ─ submit ───────────────────┘ │ ├──▶ webhooks +Nitrate admin-console (server) ─ POST push-audit-events ──▶│ ─▶ audit_log table (buffer) ──┬─ cron 0s every 5m ──▶ Nexus (archive) +Penpot backend (RPC wrap-audit) ─ submit ───────────────────┘ │ └─ cron 30s every 5m ─▶ admin console (archive) + │ ├──▶ webhooks │ ├──▶ error reporters └──▶ telemetry (shadow rows) ``` @@ -44,7 +45,7 @@ Rule of thumb: if you need to know for sure "did this user do X?", look at `sour ## 3. The `audit_log` table: a buffer, not a store -The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until the archive cron sends them to Nexus and the cleanup cron (GC) deletes them. +The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until every active archiver (Nexus, admin console) confirms them, and whoever finishes last deletes the row. Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table.sql` and later ones): @@ -56,15 +57,16 @@ Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table - `source` text: `backend` / `frontend` for full rows, `telemetry:backend` / `telemetry:frontend` for anonymized copies (see section 10). - `ip_addr` inet: real IP (or `0.0.0.0` on telemetry rows). - `props` / `context` jsonb holding transit-encoded maps: action data and metadata (versions, browser, request-id...). -- `archived_at` timestamptz: NULL until Nexus confirms receipt; only then the row can be deleted. +- No `archived_at`: the `audit_log_pending` queue (migration `0157`, archiver `1` = Nexus, `2` = admin console, ids never reused) tracks what is left. Every record is born pending only for the archivers enabled at insert time (never a fixed both): cheaper on single-archiver hosts and true to the design doc. Consequence, accepted on purpose: rows born with no send flag on get no pendings, so enabling archiving later does not pick them up — the sweep deletes them unarchived. Indexes (each consumer has its own, so a slow consumer never blocks the others): -- Partial `created_at WHERE archived_at IS NULL`: used by the archive scan. -- Partial `archived_at WHERE archived_at IS NOT NULL`: used by the GC. +- `audit_log_pending` PK `(archiver, audit_log_id)`: each archiver owns its queue at constant cost, even with millions pending. +- `audit_log (created_at, id)` (migration `0157`): the chronological drain order (`ORDER BY created_at ASC, id ASC`; same total order for both archivers, so concurrent drains cannot deadlock). +- `audit_log_pending (audit_log_id)`: serves the "is anything still pending?" check of the final delete. - `(source, created_at)`: used by the telemetry scan. -Life of a row: `INSERT` → (every 5 min) archive sets `archived_at = now()` **only if Nexus answers 204**, in the same transaction → (every 5 min) GC runs `DELETE WHERE archived_at IS NOT NULL` **with no age filter**. So archive must run before GC, or data is deleted without being sent. +Life of a row: `INSERT` (row plus one pending per enabled archiver, same transaction; a rollback takes both) → (every 5 min, Nexus at second 0 and admin console at second 30) each archive job sends its own 128-row batches oldest-first and deletes its pendings in the same transaction → whoever deletes the last pending deletes the row. A batch the destination refuses rolls back and stays for the next tick. Pendings of archivers that are off are reaped at the start of each job run (covers later deactivations), and orphan rows are swept at the end of each run, so there is no separate GC task. The FK is plain `DEFERRABLE` (never `INITIALLY DEFERRED`): every deleter removes pendings before rows, including the telemetry task's own deletes. ## 4. Anatomy of an event @@ -271,17 +273,19 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert - **No personal data is expected.** Profile ids are random UUIDs with no PII, so the telemetry consumer needs no login guard; grouping is by `instance`, not by person (see posthog-telemetry below). - **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). A body the parser cannot read never reaches the handler: it answers 400 and counts in `nexus_http_malformed_requests_total`. Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs). -## 12. Archive to Nexus (today) and to Nitrate (future) +## 12. Archive to Nexus and to the admin console -### Nexus: how it works today +### How it works today -Nexus is the long-term store. Every 5 min the `:audit-log-archive` cron takes 128-row chunks of unarchived rows (`FOR UPDATE SKIP LOCKED`, ordered by `created_at`), POSTs them as transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri, and marks `archived_at = now()` **only on HTTP 204**, in the same transaction. Anything else is retried next run; flag on but no URI raises `:task-not-configured`. It loops with 100 ms pauses until empty. +Every 5 min each archive job drains its own queue (`:audit-log-archive-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-archive-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:archive-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-archiver` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). On the other side, Nexus checks `origin` against its allowlist (403 if not allowed), coerces the events, and inserts them into its own `audit_log` (partitioned by `received_at`, with an incrementing sequence number and `source`). Watch the rename: Penpot's `created-at` is stored as **`received-at`** in Nexus; Nexus's `created-at` is when it was created there. `tracked-at` keeps the origin time. Telemetry batches arriving at `telemetry.penpot.app` land in the same table through the telemetry endpoint above. -Concurrent runs are safe: rows are locked with `FOR UPDATE SKIP LOCKED` inside the same transaction that sends and marks them, so two archivers running at once each take different rows. Only rows Nexus acknowledges (HTTP 204) get marked; anything else is retried on the next run. +Concurrent jobs are safe: both lock rows in `created_at ASC, id ASC` order (one total order, deliberately blocking — no `SKIP LOCKED`, so archive arrival stays chronological), so the second waits for the first to commit and then sees whether it was last. Only acknowledged work deletes pendings; an admin-console failure never blocks the Nexus send of the same rows (each job owns its queue). + +Observability (`backend/src/app/loggers/audit/archive_task.clj`, definitions in `backend/src/app/main.clj:default-metrics`): one `dbg` summary per job run (`:archiver` label, sent/skipped/chunks, reaped disabled pendings, swept orphans, `:elapsed` as `"1.5s"` via `ct/format-duration`, frozen in a strict binding first because log props evaluate lazily in the logger thread), one `dbg` per admin-console send call (`ready` with totals, `sent`/`failed` with counts), plus `dbg` when the queue is empty or the job's send flag is off. Queue depth is Grafana's business: it queries the database directly, so the run performs no `GROUP BY` for the logs. No `warn` before a throw: the runner reports the aborted job (refusal details travel in the error data). Metrics, all recorded with bounded labels only: `penpot_audit_archive_events_total{ destination = nexus|admin-console, outcome = archived|failed|skipped }` (`skipped` = filtered by the admin-console allowlist) and `penpot_audit_archive_discarded_total{ reason = reaped|swept }` (entries discarded without archiving: `reaped` counts pendings dropped for disabled archivers, `swept` counts rows deleted while nobody waited for them — the silent R3 loss, now dashboard-visible instead of log-only). Timing needs no custom histogram: the jobs substrate already records execution time per job (`jobs-execution-timing`, split by job name), so each archive job is timed as itself. No queue-depth gauge: pending depth is read off the replica database directly. The bulk frontend ingest counts `penpot_audit_ingest_events_total{ kind = full|shadow, outcome = inserted|failed }` (both faces share one transaction, so a failure drops the batch on both). Archive success counts are recorded by `run-single-archiver` on the drain totals: a chunk that returns normally committed its transaction (a rollback always throws instead), so they cannot inflate. Logs never carry event data (`props`, `context`, `profile-id`, `ip-addr`, `team-name`, emails): only counts, statuses and the uri host. ### How Nexus consumes the data (and forwards to PostHog) @@ -291,20 +295,16 @@ Stored rows are not the end: a consumer pipeline reads them back in sequence ord 2. **`posthog`** (runs after `local`): forwards our own SaaS archived events to PostHog. It sends an allowlist of backend track events (teams, members, projects, files, comments, Nitrate/SSO, ...) plus frontend events minus a noise exclusion list, grouped by **team**, with person identify data from the local profile mirror and snake_case props. Both paths include the `initiator` property when the stored context has one, so PostHog can split Penpot vs admin-console traffic. 3. **`posthog-telemetry`**: forwards events with `source = telemetry:frontend|telemetry:backend` to a separate PostHog project. Because these rows are already anonymous (no IPs, truncated times, filtered props), grouping is by **instance** (the self-hosted server, from `context.instance-id`), with one `$groupidentify` event per instance per chunk so PostHog learns per-instance properties (version, URI, flags). Each consumer has its own PostHog credentials. -So the full journey is: Penpot buffer → Nexus `audit_log` (via archive cron or telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. +So the full journey is: Penpot buffer → Nexus `audit_log` (via either archive cron or the telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. -### Nitrate: what exists and what is planned +### Admin console: the second archiver -Today **only Nexus archives**. Archiving Penpot → Nitrate is in development and not on this branch. - -What already exists (do not confuse with archiving): +Penpot → admin-console archiving lives in its own job (`:audit-log-archive-admin-console`, archiver `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): - **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user. - **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b). - **Penpot frontend Nitrate bits**: membership/subscription events (`frontend/src/app/main/data/nitrate.cljs`, `nitrate-audit/*`) through the same public endpoint. -The future Penpot → Nitrate archive will follow the Nexus pattern (cron reading the buffer and POSTing batches), but there is no contract or flag on this branch yet. When it lands, document here: endpoint, auth, format, which `source` values travel, and how it shares the GC. - ## 13. Flags and config Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=enable-`): @@ -312,8 +312,8 @@ Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=ena | Flag | What it does | |---|---| | `:audit-log` | Stores full rows; turns on `wrap-audit` and frontend ingest | -| `:audit-log-archive` | Turns on the cron that sends to Nexus (needs `:audit-log-archive-uri`) | -| `:audit-log-gc` | Turns on the cron that deletes archived rows | +| `:nexus` | Send flag of the Nexus archive job `:audit-log-archive-nexus` (cron `0 */5 * * * ?`; needs `:audit-log-archive-uri`) | +| `:admin-console` | Send flag of the admin-console archive job `:audit-log-archive-admin-console` (cron `30 */5 * * * ?`, 30s after Nexus); also the module flag for the admin-console integration | | `:audit-log-logger` | Structured `app.audit` log per event | | `:error-reporting` | Starts the error reporters (off by default); database reporter on flag alone, Mattermost on flag plus webhook URL | | `:telemetry` | Stores shadow rows and turns on shipping (auto with `:telemetry-enabled`) | @@ -337,6 +337,6 @@ Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri - Penpot frontend producer: `frontend/src/app/main/data/event.cljs` (collector, buffer, send), `frontend/src/app/main/repo.cljs` + `frontend/src/app/util/http.cljs` (headers), `frontend/src/app/main/data/nitrate.cljs` (Nitrate membership events). - Admin-console producer (private repo, no paths cited here): server-side organization/subscription events through the management `push-audit-events`, browser-side UI events through the public `push-audit-events`. - Consumers: `backend/src/app/loggers/webhooks.clj`, `backend/src/app/loggers/database.clj`, `backend/src/app/loggers/mattermost.clj`, `backend/src/app/tasks/telemetry.clj`. -- Archive/GC: `backend/src/app/loggers/audit/archive_task.clj`, `backend/src/app/loggers/audit/gc_task.clj`, wiring and crons in `backend/src/app/main.clj`. +- Archive (two jobs sharing one namespace + pending queue, no GC): `backend/src/app/loggers/audit/archive_task.clj` (`:audit-log-archive-nexus` / `:audit-log-archive-admin-console` job-defs, shared drain/reap/sweep helpers), producer hook in `backend/src/app/loggers/audit.clj` (`append-audit-entry`, single `queue-pendings` helper) plus the bulk ingest in `backend/src/app/rpc/commands/audit.clj` (`handle-events`), metrics/cron wiring in `backend/src/app/main.clj` (one cron entry per job, Nexus at second 0 and admin console at second 30, each gated by its own send flag), single migration `0157` (queue, drop of `archived_at`, covering `(created_at, id)` index for the chronological scan; no backfill — pre-existing rows are swept unsent, an accepted loss). - Nexus (private repo, no paths cited here): archive ingest, ordered consumers (local profile mirror, PostHog SaaS events, PostHog telemetry), telemetry endpoint, analytics views. - Tests: `backend_tests/rpc-audit-test.clj`, `rpc-team-test/accept-organization-invitation-audit-event`. diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 7c77a710d7..619c367a49 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -21,6 +21,7 @@ [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.jobs :as jobs] + [app.loggers.audit.archive-task :as archive] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] @@ -241,8 +242,13 @@ (update :props db/tjson) (update :context db/tjson) (update :ip-addr db/inet)) - params (select-keys params event-keys)] - (db/insert! cfg :audit-log params))) + params (select-keys params event-keys) + row (db/insert! cfg :audit-log params)] + ;; The archive queue follows the row in the same transaction: one + ;; pending per enabled archiver. A rollback takes both on its way + ;; out, so a row never outlives its queue. + (archive/queue-pendings cfg [(:id row)]) + row)) (def ^:private xf:filter-telemetry-props "Transducer that keeps only map entries whose values are UUIDs, @@ -582,6 +588,8 @@ logic." [cfg event] (when (contains? cf/flags :audit-log) + ;; Transactional (not a bare connection): the row and its + ;; pendings commit or vanish together (see append-audit-entry). (let [tnow (ct/now) event (-> event (assoc :created-at tnow) @@ -592,4 +600,4 @@ (update :source d/nilv "backend") (select-keys event-keys) (check-event))] - (db/run! cfg append-audit-entry event)))) + (db/tx-run! cfg append-audit-entry event)))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 6c29c29d6b..a4eb14e5e6 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -9,20 +9,77 @@ [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.schema :as sm] + [app.common.time :as ct] [app.common.transit :as t] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.db.sql :as sql] [app.http.client :as http] [app.jobs :as jobs] + [app.metrics :as mtx] [app.nitrate :as nitrate] [app.setup :as-alias setup] [integrant.core :as ig] [promesa.exec :as px])) -;; This is a task responsible to send the accumulated events to -;; external service for archival. +;; This task drains the audit log buffer into the long-term archives. +;; +;; Two separate jobs share this namespace and the queue helpers, one +;; per destination: `:audit-log-archive-nexus` and +;; `:audit-log-archive-admin-console`. Each job drains only its own queue +;; and deletes its pendings on completion; whoever deletes the last +;; pending also deletes the record ("last one out turns off the +;; light"), so no separate GC task is needed. Housekeeping (reaping +;; disabled pendings, sweeping orphans) lives in both jobs on purpose: +;; only the enabled job runs, so it must clean up for the rest. + +;; Archiver ids are fixed constants, never reuse one: retired +;; pendings are reaped by id, so a reused id would inherit the +;; previous owner's queue. +(def archiver-nexus 1) +(def archiver-admin-console 2) + +(def ^:private archivers + [{:id archiver-nexus :flag :nexus :label "nexus"} + {:id archiver-admin-console :flag :admin-console :label "admin-console"}]) + +(def ^:private xf:enabled-archivers + "Static transducer: keep the archivers whose send flag is on. + Allocated once so hot paths (every audited RPC) do not rebuild + it per call; the flag lookup itself still reads the live flags." + (comp (filter #(contains? cf/flags (:flag %))) + (map :id))) + +(defn enabled-archivers + "Archivers allowed to work right now, from the send flags. Public: + producers queue every record for exactly these archivers." + [] + (into [] xf:enabled-archivers archivers)) + +(defn queue-pendings + "Queue records for every enabled archiver, nothing when there is + nothing to queue or no archiver is on. One multi-row statement for + all ids instead of one per archiver: this runs inside every audited + RPC and the bulk frontend ingest. Runs inside the row transaction: + a rollback takes both." + [cfg ids] + (let [archivers (enabled-archivers)] + (when (and (seq ids) (seq archivers)) + (db/insert-many! cfg :audit-log-pending + [:audit-log-id :archiver] + (for [id ids + archiver archivers] + [id archiver]) + {::sql/on-conflict-do-nothing true})))) + +(def ^:private archiver-labels + (into {} (map (juxt :id :label)) archivers)) + +(defn- archiver-label + [archiver] + (get archiver-labels archiver)) (defn- decode-row [{:keys [props ip-addr context] :as row}] @@ -51,29 +108,95 @@ [row] (select-keys row event-keys)) -(defn- send! +(defn- record-count + [metrics id labels amount] + (mtx/run! metrics :id id :labels labels :inc amount)) + +(defn- truncate-body + [body limit] + (when (some? body) + (let [s (str body)] + (if (> (count s) limit) + (subs s 0 limit) + s)))) + +;; NOTE: deliberately no SKIP LOCKED here. Batches must reach the +;; destinations in chronological order (Nexus numbers by arrival), +;; so every archiver takes the same total order and a second +;; overlapping run waits for the first to commit, then sees whether +;; it was last. The brief blocking is accepted; skipping rows could +;; reorder archive arrival. +(def ^:private sql:pending-chunk + "SELECT a.* + FROM audit_log_pending p + JOIN audit_log a ON a.id = p.audit_log_id + WHERE p.archiver = ?::smallint + ORDER BY a.created_at ASC, a.id ASC + LIMIT 128 + FOR UPDATE OF a") + +(def ^:private sql:delete-pendings + "DELETE FROM audit_log_pending + WHERE archiver = ?::smallint + AND audit_log_id = ANY(?)") + +(def ^:private sql:delete-drained + "DELETE FROM audit_log a + WHERE a.id = ANY(?) + AND NOT EXISTS ( + SELECT 1 FROM audit_log_pending p + WHERE p.audit_log_id = a.id + )") + +(defn- get-pending-rows + [{:keys [::db/conn] :as cfg} archiver] + (->> (db/exec! conn [sql:pending-chunk archiver]) + (not-empty))) + +(defn- complete-batch + "Delete our pendings for the batch, then the records nobody is + waiting for anymore. Two statements on purpose: inside a single + statement the NOT EXISTS would still see the pending just deleted + and never delete a record." + [{:keys [::db/conn]} archiver rows] + (let [ids (db/create-array conn "uuid" (map :id rows))] + (db/exec! conn [sql:delete-pendings archiver ids]) + (db/exec! conn [sql:delete-drained ids]))) + +(defn- send-to-nexus + "POST events to Nexus. Returns the number of events sent; throws on + transport failure or non-204 refusal, carrying the attempted count + as :events in the error data so the caller records the failure. + No metrics or logs here: the runner reports the aborted job." [{:keys [::uri] :as cfg} events] - (let [skey (-> cfg ::setup/shared-keys :nexus) - body (t/encode {:events events}) - headers {"content-type" "application/transit+json" - "origin" (str (cf/get :public-uri)) - "x-shared-key" (str "nexus " skey)} - params {:uri uri - :timeout 12000 - :method :post - :headers headers - :body body} - resp (http/req cfg params {:skip-ssrf-check? true})] - + (let [n (count events) + skey (-> cfg ::setup/shared-keys :nexus) + body (t/encode {:events events}) + params {:uri uri + :timeout 12000 + :method :post + :headers {"content-type" "application/transit+json" + "origin" (str (cf/get :public-uri)) + "x-shared-key" (str "nexus " skey)} + :body body} + resp (try + (http/req cfg params {:skip-ssrf-check? true}) + (catch Throwable cause + (throw (ex/raise :type :internal + :code :nexus-send-failed + :hint "unable to send events to nexus" + :cause cause + :events n))))] (if (= (:status resp) 204) - true - (do - (l/error :hint "unable to archive events" - :resp-status (:status resp) - :resp-body (:body resp)) - false)))) + n + (throw (ex/raise :type :internal + :code :archive-chunk-refused + :hint "nexus refused archive chunk" + :resp-status (:status resp) + :resp-body (truncate-body (:body resp) 500) + :events n))))) -(def ^:private event-names-for-nitrate +(def ^:private event-names-for-admin-console #{"accept-organization-invitation" "accept-team-invitation" "accept-team-invitation-from" @@ -121,7 +244,7 @@ "update-team-photo" "verify-token"}) -;; Props id key -> email prop filled from profile.email (nitrate path only). +;; Props id key -> email prop filled from profile.email (admin-console path only). ;; Some producers store ids as UUID strings (e.g. :user-who-send-invitation). (def ^:private id->email-prop {:profile-id :profile-email @@ -187,8 +310,8 @@ props id->email-prop)))) -(defn- enrich-events-for-nitrate - "Enrich allowlisted events before nitrate ingest: +(defn- enrich-events-for-admin-console + "Enrich allowlisted events before the admin-console ingest: 1. Fill missing :team-name from team id (including soft-deleted). 2. Fill missing emails from matching id keys (including soft-deleted profiles): :profile-email, :user-email, :member-email, @@ -204,103 +327,304 @@ (maybe-assoc-emails emails))) events))) -(defn- send-to-nitrate! +(defn- send-to-admin-console + "Ingest rows into the admin console. Returns {:sent :skipped} + (filtered-out rows count as processed for the queue); throws + carrying the attempted count as :events, like send-to-nexus. Enrich + failures propagate untouched: a local DB/decode failure is ours, not + the destination's, and must not count as a send failure." [cfg rows] - (when-let [events (->> rows - (filterv #(contains? event-names-for-nitrate (:name %))) - (not-empty))] - (nitrate/call cfg :ingest-audit-log - {:events (enrich-events-for-nitrate cfg events)}))) + (let [events (filterv #(contains? event-names-for-admin-console (:name %)) rows) + total (count rows) + skipped (- total (count events)) + uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)] + (l/dbg :hint "archive events chunk ready" + :archiver "admin-console" + :uri-host uri-host + :total total + :skipped skipped) + (if (empty? events) + {:sent 0 :skipped skipped} + ;; Enrich first, outside the try: a local DB/decode failure is + ;; ours, not the destination's, and must not count as + ;; admin-console/failed. + (let [enriched (enrich-events-for-admin-console cfg events) + n (count events)] + (try + (nitrate/call cfg :ingest-audit-log {:events enriched}) + (l/dbg :hint "archive events chunk sent" + :archiver "admin-console" + :uri-host uri-host + :sent n + :skipped skipped) + {:sent n :skipped skipped} + (catch Throwable cause + (l/dbg :hint "archive events chunk failed" + :archiver "admin-console" + :uri-host uri-host + :events n + :code :admin-console-send-failed) + (throw (ex/raise :type :internal + :code :admin-console-send-failed + :hint "unable to archive events to the admin console" + :cause cause + :events n)))))))) -(defn- mark-archived! - [{:keys [::db/conn]} rows] - (let [ids (db/create-array conn "uuid" (map :id rows))] - (db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)" ids]))) +(defn- archive-nexus-chunk + "Send one pending chunk to Nexus and complete it. Returns {:sent n} + or nil when the queue is empty; throws on refusal. Takes a + transactional cfg: the caller opens one transaction per chunk. No + metrics here: a normal return means the transaction committed, so + the caller records the success." + [cfg] + (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) + (when-let [rows (get-pending-rows cfg archiver-nexus)] + (let [events (mapv row->event (mapv decode-row rows)) + ;; send-to-nexus returns the count or throws: a refusal + ;; aborts the job, the rows stay for the next tick. + n (send-to-nexus cfg events)] -(def ^:private sql:get-audit-log-chunk - "SELECT * - FROM audit_log - WHERE archived_at IS NULL - ORDER BY created_at ASC - LIMIT 128 - FOR UPDATE - SKIP LOCKED") + (l/trc :hint "archive events chunk" + :archiver "nexus" + :events n) -(defn- get-event-rows - [{:keys [::db/conn] :as cfg}] - (->> (db/exec! conn [sql:get-audit-log-chunk]) - (not-empty))) + (complete-batch cfg archiver-nexus rows) + {:sent n}))) -(defn- archive-events! - [{:keys [::uri] :as cfg}] - (db/tx-run! cfg (fn [cfg] - (when-let [rows (get-event-rows cfg)] - (let [decoded (mapv decode-row rows) - events (mapv row->event decoded)] - (l/trc :hint "archive events chunk" :uri uri :events (count events)) +(defn- archive-admin-console-chunk + "Send one pending chunk to the admin console and complete it. Returns + {:sent :skipped} or nil when the queue is empty; throws on failure. + Takes a transactional cfg, no metrics here: same contract as + archive-nexus-chunk." + [cfg] + (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) + (when-let [rows (get-pending-rows cfg archiver-admin-console)] + (let [decoded (mapv decode-row rows) + _ (l/trc :hint "archive events chunk" + :archiver "admin-console" + :events (count decoded)) + admin-console-res (send-to-admin-console cfg decoded)] + ;; Filtered-out rows count as processed for the + ;; admin-console queue: their pending is done, the + ;; record stays until every archiver is done. + (complete-batch cfg archiver-admin-console rows) + admin-console-res))) - (when (contains? cf/flags :admin-console) - (send-to-nitrate! cfg decoded)) +(defn- drain-archiver + "Drain one archiver queue chunk by chunk, one transaction per chunk. + Returns totals; a chunk the destination refuses (or that throws) + aborts the drain with a throw, and the committed chunks so far + stay sent." + [cfg send-chunk] + (loop [sent 0 + skipped 0 + chunks 0] + ;; One transaction per chunk: fetch, send and complete commit (or + ;; roll back) together, and the next chunk sees a fresh snapshot. + (if-let [res (db/tx-run! cfg send-chunk)] + (do + (jobs/heartbeat cfg) + (px/sleep 100) + (recur (+ sent ^long (:sent res)) + (+ skipped ^long (:skipped res 0)) + (inc chunks))) + {:sent sent :skipped skipped :chunks chunks}))) - ;; When :nexus is off, treat Nexus as skipped - ;; success so nitrate-only still marks the chunk. - ;; REPL :enabled true with neither send flag also marks - ;; with no outbound send — always set at least one flag. - (let [nexus-ok? (if (contains? cf/flags :nexus) - (send! cfg events) - true)] - (when nexus-ok? - (mark-archived! cfg rows) - (count events)))))))) +(defn- sql:reap-pendings + [archiver] + (str "DELETE FROM audit_log_pending" + " WHERE archiver = " archiver + " AND audit_log_id IN (SELECT audit_log_id FROM audit_log_pending" + " WHERE archiver = " archiver + " ORDER BY audit_log_id LIMIT 5000)")) -(declare execute-audit-log-archive) +(def ^:private sql:sweep-orphans + "DELETE FROM audit_log a + WHERE a.id IN (SELECT a2.id FROM audit_log a2 + WHERE NOT EXISTS (SELECT 1 FROM audit_log_pending p + WHERE p.audit_log_id = a2.id) + ORDER BY a2.id LIMIT 5000)") -(def schema:audit-log-archive-params - "Optional overrides for the repl invocation defaults. +(defn- delete-batched + "Run a batched DELETE until it removes nothing. Returns the total." + [cfg sql] + (loop [total 0] + (let [n (db/get-update-count (db/exec-one! cfg [sql]))] + (if (pos? n) + (do + (px/sleep 100) + (recur (+ total ^long n))) + total)))) + +(defn- reap-disabled + "Drop the pendings of archivers that are off: a disabled destination + will never drain them, and without this they would pin their records + forever. This also absorbs any later deactivation, with no manual step. + Dropped pendings count as discarded: their rows may still be archived + by an enabled archiver, or swept unsent when nothing waits for them." + [cfg] + (let [total (->> archivers + (remove #(contains? cf/flags (:flag %))) + (map #(delete-batched cfg (sql:reap-pendings (:id %)))) + (reduce + 0))] + (when (pos? total) + (record-count (mtx/instance cfg) :audit-archive-discarded ["reaped"] total)) + total)) + +(defn- sweep-orphans + "Delete records nobody waits for anymore: rows born while no + archiver was on, rows left pending-less by the reap, and leftovers + archived by the old task. Batched and normally a no-op. Deleted rows + count as discarded: on a live system they were never archived." + [cfg] + (let [total (delete-batched cfg sql:sweep-orphans)] + (when (pos? total) + (record-count (mtx/instance cfg) :audit-archive-discarded ["swept"] total)) + total)) + +(declare execute-audit-log-archive-nexus) +(declare execute-audit-log-archive-admin-console) + +(def schema:audit-log-archive-nexus-params + "Overrides for the nexus job repl invocation defaults. Cron submits {}; system deps (pool, shared-keys, http) live on the - job-def cfg closed over by the handler, not in job params." - [:map - [:enabled {:optional true} :boolean] + job-def cfg closed over by the handler, not in job params. + Closed on purpose: a stale key (like the old :enabled) must fail + validation loudly instead of skipping silently." + [:map {:closed true} [:uri {:optional true} ::sm/uri]]) -(defmethod ig/init-key ::job-def +(def schema:audit-log-archive-admin-console-params + "Params of the admin-console job: none. Cron submits {}; system deps + (pool, admin-console client) live on the job-def cfg closed over by + the handler, not in job params. Closed on purpose, same as the nexus + schema." + [:map {:closed true}]) + +(defmethod ig/assert-key ::nexus-job-def [_ cfg] - {::jobs/name :audit-log-archive - ::jobs/schema schema:audit-log-archive-params + (assert (db/pool? (::db/pool cfg)) "expected a valid database pool") + (assert (http/client? (::http/client cfg)) "expected a valid http client") + (assert (mtx/metrics? (::mtx/metrics cfg)) "expected a valid metrics instance")) + +(defmethod ig/init-key ::nexus-job-def + [_ cfg] + ;; Effective destination, once at boot: per-run logs carry counts, + ;; the runner says the job started, this says where it sends. + (l/dbg :hint "audit log archive job initialized" + :archiver "nexus" + :uri-host (some-> (cf/get :audit-log-archive-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-archive-nexus + ::jobs/schema schema:audit-log-archive-nexus-params ::jobs/handler (fn [_context params] - (execute-audit-log-archive cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-archive-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-archive-params)}) + (execute-audit-log-archive-nexus cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-archive-nexus-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-archive-nexus-params)}) -(defn execute-audit-log-archive - "Plain job handler: archive the accumulated audit events in chunks +(defmethod ig/assert-key ::admin-console-job-def + [_ cfg] + ;; No assert on the admin-console client: it inits to nil without the + ;; :admin-console flag, and the job skips on its flag anyway. + (assert (db/pool? (::db/pool cfg)) "expected a valid database pool") + (assert (mtx/metrics? (::mtx/metrics cfg)) "expected a valid metrics instance")) + +(defmethod ig/init-key ::admin-console-job-def + [_ cfg] + ;; Same as the nexus job-def: destination host once at boot. The + ;; ingest uri is resolved by nitrate/call at send time (it raises + ;; when missing); here it is display-only. + (l/dbg :hint "audit log archive job initialized" + :archiver "admin-console" + :uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-archive-admin-console + ::jobs/schema schema:audit-log-archive-admin-console-params + ::jobs/handler + (fn [_context params] + (execute-audit-log-archive-admin-console cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-archive-admin-console-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-archive-admin-console-params)}) + +(defn- run-single-archiver + "Drain one archiver queue, then housekeeping (reap disabled + pendings, sweep orphans) and the per-run summary. Housekeeping lives + in both jobs: only the enabled job runs, so it must reap the queues + of the archivers that are off; both halves are idempotent deletes. + A refused chunk (or a throw) aborts the job: the committed chunks + stay sent, the rest waits for the next tick, and the cron retries. + Every effect is rows and logs (drain metrics land here, outside + every chunk transaction)." + [cfg archiver send-chunk tpoint] + (let [label (archiver-label archiver) + metrics (mtx/instance cfg) + reaped (reap-disabled cfg) + ;; All drain metrics land here, outside every chunk + ;; transaction. Success is recorded on the totals: a normal + ;; chunk return means its transaction committed (a rollback + ;; always throws, taking the failure path instead). The send + ;; fns carry the attempted count as :events; anything else (a + ;; local DB failure) is not a destination failure and stays + ;; uncounted. The throw keeps rolling: a failed chunk aborts + ;; the job, the cron retries. + drained (try + (drain-archiver cfg send-chunk) + (catch Throwable cause + (let [n (:events (ex-data cause))] + (when (and (integer? n) (pos? n)) + (record-count metrics :audit-archive-events [label "failed"] n))) + (throw cause))) + _ (let [{:keys [sent skipped]} drained] + (when (pos? sent) + (record-count metrics :audit-archive-events [label "archived"] sent)) + (when (pos? skipped) + (record-count metrics :audit-archive-events [label "skipped"] skipped))) + swept (sweep-orphans cfg) + sent (:sent drained) + skipped (:skipped drained) + chunks (:chunks drained) + ;; Strict binding on purpose: log props evaluate lazily in + ;; the logger thread, so the elapsed must be frozen here. + elapsed (ct/format-duration (tpoint))] + (if (and (zero? sent) + (zero? skipped) + (zero? reaped) + (zero? swept)) + (l/dbg :hint "no pending audit events" + :archiver label) + (l/dbg :hint "events archived" + :archiver label + :elapsed elapsed + :sent sent + :skipped skipped + :chunks chunks + :reaped reaped + :swept swept)))) + +(defn execute-audit-log-archive-nexus + "Nexus job handler: drain the nexus queue in chunks, then clean up (heartbeat per iteration: the sent chunk batches can be long)." [cfg params] ;; NOTE: this let allows overwrite default configured values from - ;; the repl, when manually invoking the task. Prefer setting a send - ;; flag (:admin-console and/or :nexus); :enabled alone - ;; marks chunks without shipping. - (let [enabled (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console) - (:enabled params false)) - uri (cf/get :audit-log-archive-uri) - uri (or uri (:uri params)) - ;; Normalize to an uri object; params may carry a plain string - ;; on direct invocations (validation only applies on submit!). - uri (u/uri uri) - cfg (assoc cfg ::uri uri)] - (when (and (contains? cf/flags :nexus) - (not uri)) - (ex/raise :type :internal - :code :task-not-configured - :hint "archive task not configured, missing uri")) - (when enabled - (loop [total 0] - (if-let [n (archive-events! cfg)] - (do - (jobs/heartbeat cfg) - (px/sleep 100) - (recur (+ total ^long n))) - (when (pos? total) - (l/dbg :hint "events archived" :total total))))))) + ;; the repl, when manually invoking the task. + (if-not (contains? cf/flags :nexus) + (l/dbg :hint "audit log archive (nexus) skipped, flag off") + (let [uri (cf/get :audit-log-archive-uri) + uri (or uri (:uri params)) + ;; Normalize to an uri object; params may carry a plain string + ;; on direct invocations (validation only applies on submit!). + uri (u/uri uri) + cfg (assoc cfg ::uri uri)] + (when-not uri + (ex/raise :type :internal + :code :task-not-configured + :hint "archive task not configured, missing uri")) + (run-single-archiver cfg archiver-nexus archive-nexus-chunk (ct/tpoint))))) + +(defn execute-audit-log-archive-admin-console + "Admin-console job handler: drain the admin-console queue in chunks, + then clean up (heartbeat per iteration: the sent chunk batches can + be long)." + [cfg _params] + (if-not (contains? cf/flags :admin-console) + (l/dbg :hint "audit log archive (admin-console) skipped, flag off") + (run-single-archiver cfg archiver-admin-console archive-admin-console-chunk (ct/tpoint)))) diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj deleted file mode 100644 index 65cd5bec7f..0000000000 --- a/backend/src/app/loggers/audit/gc_task.clj +++ /dev/null @@ -1,46 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS SUBSIDIARY SL - -(ns app.loggers.audit.gc-task - (:require - [app.common.logging :as l] - [app.common.schema :as sm] - [app.db :as db] - [app.jobs :as jobs] - [integrant.core :as ig])) - -(def ^:private sql:clean-archived - "DELETE FROM audit_log - WHERE archived_at IS NOT NULL") - -(defn- clean-archived! - [{:keys [::db/pool]}] - (let [result (db/exec-one! pool [sql:clean-archived]) - result (db/get-update-count result)] - (l/debug :hint "delete archived audit log entries" :deleted result) - result)) - -(declare execute-audit-log-gc) - -(def schema:audit-log-gc-params - "Params map (no params needed; config-derived only)." - [:map {:closed true}]) - -(defmethod ig/init-key ::job-def - [_ cfg] - {::jobs/name :audit-log-gc - ::jobs/schema schema:audit-log-gc-params - ::jobs/handler - (fn [_context params] - (execute-audit-log-gc cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-gc-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-gc-params)}) - -(defn execute-audit-log-gc - "Plain job handler: delete the archived audit log entries." - ([cfg] (execute-audit-log-gc cfg nil)) - ([cfg _params] - (clean-archived! cfg))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 5ee2c7bbe2..5a387f14be 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -371,6 +371,24 @@ :http-malformed-requests-total {::mdef/name "penpot_http_malformed_requests_total" ::mdef/help "Total number of requests whose body the parser could not read." + ::mdef/type :counter} + + :audit-archive-events + {::mdef/name "penpot_audit_archive_events_total" + ::mdef/help "Audit log events sent by the archive task, by destination and outcome." + ::mdef/labels ["destination" "outcome"] + ::mdef/type :counter} + + :audit-archive-discarded + {::mdef/name "penpot_audit_archive_discarded_total" + ::mdef/help "Audit log entries discarded without archiving, by reason." + ::mdef/labels ["reason"] + ::mdef/type :counter} + + :audit-ingest-events + {::mdef/name "penpot_audit_ingest_events_total" + ::mdef/help "Audit log frontend events ingested, by kind and outcome." + ::mdef/labels ["kind" "outcome"] ::mdef/type :counter}}) (def system-config @@ -604,25 +622,25 @@ ::setup/shared-keys (ig/ref ::setup/shared-keys)} ::jobs/defs - {:sendmail (ig/ref ::email/job-def) - :delete-object (ig/ref :app.tasks.delete-object/job-def) - :demo-purge (ig/ref :app.tasks.demo-purge/job-def) - :run-webhook (ig/ref ::webhooks/run-webhook-job-def) - :process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def) - :file-gc (ig/ref :app.tasks.file-gc/job-def) - :offload-file-data (ig/ref :app.tasks.offload-file-data/job-def) - :objects-gc (ig/ref :app.tasks.objects-gc/job-def) - :export-binfile (ig/ref :app.tasks.export-binfile/job-def) - :import-binfile (ig/ref :app.tasks.import-binfile/job-def) - :storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def) - :storage-gc-touched (ig/ref ::sto.gc-touched/job-def) - :storage-pending-gc (ig/ref ::sto.pending-gc/job-def) - :jobs-gc (ig/ref :app.jobs.gc/job-def) - :telemetry (ig/ref :app.tasks.telemetry/job-def) - :session-gc (ig/ref ::session/job-def) - :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) - :audit-log-archive (ig/ref :app.loggers.audit.archive-task/job-def) - :audit-log-gc (ig/ref :app.loggers.audit.gc-task/job-def)} + {:sendmail (ig/ref ::email/job-def) + :delete-object (ig/ref :app.tasks.delete-object/job-def) + :demo-purge (ig/ref :app.tasks.demo-purge/job-def) + :run-webhook (ig/ref ::webhooks/run-webhook-job-def) + :process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def) + :file-gc (ig/ref :app.tasks.file-gc/job-def) + :offload-file-data (ig/ref :app.tasks.offload-file-data/job-def) + :objects-gc (ig/ref :app.tasks.objects-gc/job-def) + :export-binfile (ig/ref :app.tasks.export-binfile/job-def) + :import-binfile (ig/ref :app.tasks.import-binfile/job-def) + :storage-gc-deleted (ig/ref ::sto.gc-deleted/job-def) + :storage-gc-touched (ig/ref ::sto.gc-touched/job-def) + :storage-pending-gc (ig/ref ::sto.pending-gc/job-def) + :jobs-gc (ig/ref :app.jobs.gc/job-def) + :telemetry (ig/ref :app.tasks.telemetry/job-def) + :session-gc (ig/ref ::session/job-def) + :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) + :audit-log-archive-nexus (ig/ref :app.loggers.audit.archive-task/nexus-job-def) + :audit-log-archive-admin-console (ig/ref :app.loggers.audit.archive-task/admin-console-job-def)} ::jobs/request-pool {::rds/client (ig/ref ::rds/client) @@ -711,16 +729,16 @@ {::db/pool (ig/ref ::db/pool) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.archive-task/job-def + :app.loggers.audit.archive-task/nexus-job-def {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) ::setup/shared-keys (ig/ref ::setup/shared-keys) - :app.nitrate/client (ig/ref :app.nitrate/client) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.gc-task/job-def - {::db/pool (ig/ref ::db/pool) - ::mtx/metrics (ig/ref ::mtx/metrics)} + :app.loggers.audit.archive-task/admin-console-job-def + {::db/pool (ig/ref ::db/pool) + :app.nitrate/client (ig/ref :app.nitrate/client) + ::mtx/metrics (ig/ref ::mtx/metrics)} ::email/blacklist {} @@ -841,15 +859,13 @@ {:cron #penpot/cron "0 30 */3,23 * * ?" :job :telemetry} - (when (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console)) + (when (contains? cf/flags :nexus) {:cron #penpot/cron "0 */5 * * * ?" ;; every 5m - :job :audit-log-archive}) + :job :audit-log-archive-nexus}) - (when (or (contains? cf/flags :nexus) - (contains? cf/flags :admin-console)) - {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m - :job :audit-log-gc})]} + (when (contains? cf/flags :admin-console) + {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m, offset 30s from nexus + :job :audit-log-archive-admin-console})]} ::wrk/dispatcher {::rds/client (ig/ref ::rds/client) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 7b25493c8d..104222cc00 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -529,7 +529,10 @@ :fn (mg/resource "app/migrations/sql/0155-add-idx-to-file-change.sql")} {:name "0156-reset-pg-storage-defaults" - :fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")}]) + :fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")} + + {:name "0157-add-audit-log-pending-table" + :fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql new file mode 100644 index 0000000000..4c6d8d998d --- /dev/null +++ b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql @@ -0,0 +1,39 @@ +-- Work queue for the audit log archivers: one row per (record, +-- archiver) pair still pending. Inserting a record creates its +-- pendings; each archiver deletes its own on completion, and whoever +-- deletes the last pending also deletes the record. Archiver ids are +-- fixed in code and never reused: 1 = nexus, 2 = nitrate. + +CREATE TABLE audit_log_pending ( + -- DEFERRABLE so the test-suite reset (which deletes table by table + -- with SET CONSTRAINTS ALL DEFERRED) keeps working; the guard still + -- fires when a record with pendings is deleted. Plain DEFERRABLE + -- (initially immediate) is enough: every deleter removes pendings + -- before rows, so the check passes at each statement. + audit_log_id uuid NOT NULL REFERENCES audit_log(id) DEFERRABLE, + archiver smallint NOT NULL CHECK (archiver BETWEEN 1 AND 20), + PRIMARY KEY (archiver, audit_log_id)); + +-- Lets the final delete ask "is anything still pending for this +-- record?" without scanning the whole queue. +CREATE INDEX ON audit_log_pending (audit_log_id); + +-- NOTE: no backfill on purpose. Rows predating this queue (sent or +-- not) get no pendings, so the orphan sweep deletes them unsent. +-- Losing the unarchived ones at upgrade time is accepted: this +-- table is a minutes/hours buffer, not a store. + +-- The pending queue replaces the archived_at mark: whoever deletes +-- the last pending deletes the record, so the flag column and the +-- indexes that served the old mark-and-sweep scans go away. +DROP INDEX IF EXISTS audit_log__created_at__idx; +DROP INDEX IF EXISTS audit_log__archived_at__idx; +DROP INDEX IF EXISTS audit_log_id_archived_at_idx; +ALTER TABLE audit_log DROP COLUMN IF EXISTS archived_at; + +-- Serves the archivers' chronological scan (ORDER BY created_at, id): +-- one total order for every archiver, so concurrent drains cannot +-- deadlock on row locks. Covers the whole ORDER BY (bulk ingest +-- shares request-at constantly, so ties are the norm). +CREATE INDEX IF NOT EXISTS audit_log__created_at__idx + ON audit_log (created_at ASC, id); diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 893160a23e..771b321606 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -16,8 +16,10 @@ [app.db :as db] [app.http :as-alias http] [app.loggers.audit :as audit] + [app.loggers.audit.archive-task :as archive] [app.loggers.database :as loggers.db] [app.loggers.mattermost :as loggers.mm] + [app.metrics :as mtx] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.doc :as-alias doc] @@ -71,6 +73,9 @@ (def ^:private xf:map-event-row (comp (map adjust-timestamp) + ;; Each row mints its own id: the full row and its telemetry + ;; shadow are distinct records that must not share it. + (map #(assoc % :id (uuid/next))) (map event->row))) (defn- prepare-events @@ -80,8 +85,7 @@ initiator (audit/get-client-initiator request) xform (comp (map (fn [event] - {:id (uuid/next) - :type (:type event) + {:type (:type event) :name (:name event) :props (:props event) ;; The initiator always comes from the @@ -113,6 +117,19 @@ (assoc :source "telemetry:frontend")))) (map event->row))) +(defn- record-ingested + [metrics kind outcome amount] + (when (pos? amount) + (mtx/run! metrics :id :audit-ingest-events :labels [kind outcome] :inc amount))) + +(defn- insert-rows! + [tx rows] + (when (seq rows) + (db/insert-many! tx :audit-log event-columns rows) + ;; Same transaction as the rows: every ingested record is born + ;; pending for every enabled archiver (see archive-task). + (archive/queue-pendings tx (map first rows)))) + (defn- handle-events [{:keys [::db/pool] :as cfg} params] (let [events (prepare-events params)] @@ -124,19 +141,30 @@ (run! (partial loggers.db/emit cfg) events) (run! (partial loggers.mm/emit cfg) events)) - (when (contains? cf/flags :audit-log) - ;; Process and save full audit events when audit-log flag is active - (when-let [rows (-> (sequence xf:map-event-row events) - (not-empty))] - (db/insert-many! pool :audit-log event-columns rows))) - - (when (contains? cf/flags :telemetry) - ;; Store anonymized frontend events so the telemetry task can ship them - ;; in batches. Runs independently from the audit-log insert above so - ;; both modes can be active simultaneously. - (when-let [rows (-> (sequence xf:map-telemetry-event-row events) - (not-empty))] - (db/insert-many! pool :audit-log event-columns rows))))) + ;; Rows and their pendings commit together in one transaction: a + ;; row never outlives its queue. Both faces are best-effort, so a + ;; failure drops the whole batch (counted below as failed); the + ;; endpoint still answers without breaking the request. + (let [metrics (mtx/instance cfg) + full-rows (if (contains? cf/flags :audit-log) + (into [] xf:map-event-row events) + []) + shadow-rows (if (contains? cf/flags :telemetry) + (into [] xf:map-telemetry-event-row events) + [])] + (try + (db/tx-run! pool + (fn [tx] + (insert-rows! tx full-rows) + (insert-rows! tx shadow-rows))) + (record-ingested metrics "full" "inserted" (count full-rows)) + (record-ingested metrics "shadow" "inserted" (count shadow-rows)) + (catch Throwable cause + ;; Whatever never landed counts as failed per attempted + ;; kind; the endpoint logs the cause as before. + (record-ingested metrics "full" "failed" (count full-rows)) + (record-ingested metrics "shadow" "failed" (count shadow-rows)) + (throw cause)))))) (def ^:private valid-event-types #{"action" "identify" "trigger"}) diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 50029746e3..8088841380 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -218,19 +218,32 @@ ;; buffer stays bounded. (def ^:private batch-size 10000) +(def ^:private sql:aged-telemetry + "source LIKE 'telemetry:%' AND created_at < now() - interval '7 days'") + +(def ^:private sql:gc-pendings + (str "DELETE FROM audit_log_pending WHERE audit_log_id IN" + " (SELECT id FROM audit_log WHERE " sql:aged-telemetry ")")) + (def ^:private sql:gc-events - "DELETE FROM audit_log - WHERE source LIKE 'telemetry:%' - AND created_at < now() - interval '7 days'") + (str "DELETE FROM audit_log WHERE " sql:aged-telemetry)) (defn- gc-events "Delete telemetry-mode events older than `telemetry-retention-days` so that the buffer stays bounded." [{:keys [::db/conn]}] - (let [result (db/exec-one! conn [sql:gc-events])] - (when (pos? (:next.jdbc/update-count result)) - (l/warn :hint "purged stale telemetry events" - :count (:next.jdbc/update-count result))))) + ;; One transaction for both deletes: a crash between them would + ;; leave pending-less rows for the archive sweep to delete unsent. + ;; Pendings first: the queue holds a row per archiver for every + ;; record, and the FK would reject deleting a row that still has + ;; pendings. + (db/tx-run! conn + (fn [{:keys [::db/conn]}] + (db/exec-one! conn [sql:gc-pendings]) + (let [result (db/exec-one! conn [sql:gc-events])] + (when (pos? (:next.jdbc/update-count result)) + (l/warn :hint "purged stale telemetry events" + :count (:next.jdbc/update-count result))))))) (def ^:private sql:fetch-telemetry-events "SELECT id, name, type, source, tracked_at, profile_id, props, context @@ -274,10 +287,17 @@ false)))) (defn- delete-sent-events - "Delete rows by their ids after a successful send." + "Delete rows by their ids after a successful send. One transaction + for both deletes: a crash between them would leave pending-less + rows for the archive sweep to delete unsent." [conn ids] - (let [arr (db/create-array conn "uuid" ids)] - (db/exec-one! conn ["DELETE FROM audit_log WHERE id = ANY(?)" arr]))) + (db/tx-run! conn + (fn [tx] + (let [tx-conn (::db/conn tx) + arr (db/create-array tx-conn "uuid" ids)] + ;; Pendings first: same FK reason as in `gc-events`. + (db/exec-one! tx-conn ["DELETE FROM audit_log_pending WHERE audit_log_id = ANY(?)" arr]) + (db/exec-one! tx-conn ["DELETE FROM audit_log WHERE id = ANY(?)" arr]))))) (defn- collect-and-send-audit-events "Collect anonymous telemetry-mode audit events and ship them to the diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index a04c2c2fd1..c0e5a5a0e0 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -9,203 +9,244 @@ [app.common.json :as json] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.transit :as transit] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http.client :as-alias http] [app.jobs :as jobs] + [app.loggers.audit :as audit] [app.loggers.audit.archive-task :as archive] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] [app.setup :as-alias setup] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str] [integrant.core :as ig] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]]) + (:import + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) (def ^:private archive-uri "http://nexus.example/archive") +(defn- insert-pendings! + "Queue the row with the real producer helper: pendings for the + enabled archivers, like append-audit-entry does." + [row-id] + (archive/queue-pendings (:app.db/pool th/*system*) [row-id])) + +(defn- pending-count + [] + (:pending (first (th/db-exec! ["SELECT count(*) AS pending FROM audit_log_pending"])))) + (defn- insert-audit-row! ([profile-id name] (insert-audit-row! profile-id name {:team-id (uuid/next)})) ([profile-id name props] - (th/db-insert! :audit-log - {:id (uuid/next) - :name name - :type "action" - :source "backend" - :profile-id profile-id - :ip-addr (db/inet "127.0.0.1") - :props (db/tjson props) - :context (db/tjson {}) - :tracked-at (ct/now) - :created-at (ct/now)}))) + (let [row (th/db-insert! :audit-log + {:id (uuid/next) + :name name + :type "action" + :source "backend" + :profile-id profile-id + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson props) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)})] + (insert-pendings! (:id row)) + row))) -(defn- nitrate-event-props - [nitrate-mock] - (let [[_ _ params] (:call-args @nitrate-mock)] +(defn- admin-console-event-props + [admin-console-mock] + (let [[_ _ params] (:call-args @admin-console-mock)] (:props (first (:events params))))) -(t/deftest archive-events-sends-to-nitrate-then-nexus - ;; Create profile before enabling :admin-console — system nitrate - ;; client is nil in tests, and team bootstrap would call it. - (let [prof (th/create-profile* 1 {:is-active true}) - order (atom [])] +(defn- run-both! + "Run both archive jobs, Nexus first (the historical single-run + order). The admin-console job takes no params (closed empty schema)." + [params] + (th/run-task! :audit-log-archive-nexus params) + (th/run-task! :audit-log-archive-admin-console {})) + +(t/deftest archive-jobs-drain-their-own-queues + ;; Two separate jobs, one queue each: the nexus job sends without + ;; touching the admin console, and only the admin-console job + ;; deletes the last pending and the row. Create profile before + ;; enabling :admin-console — the system admin-console client is nil + ;; in tests, and team bootstrap would call it. + (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call - :return (fn [& _] - (swap! order conj :nitrate) - nil)} - http-mock {:target 'app.http.client/req - :return (fn [& _] - (swap! order conj :nexus) - {:status 204})}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-file") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (:called? @nitrate-mock)) + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) - (t/is (= 1 (:call-count @nitrate-mock))) (t/is (= 1 (:call-count @http-mock))) - (t/is (= [:nitrate :nexus] @order)) - (let [[_ method params] (:call-args @nitrate-mock)] + ;; Nexus drained its own queue; both rows wait on the admin console. + (t/is (= 2 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 2 (pending-count))) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (= 1 (:call-count @admin-console-mock))) + (let [[_ method params] (:call-args @admin-console-mock)] (t/is (= :ingest-audit-log method)) (t/is (= 2 (count (:events params)))) (t/is (= #{"create-project" "create-file"} (into #{} (map :name) (:events params)))) (t/is (every? uuid? (map :id (:events params))))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 2 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-filters-nitrate-event-names +(t/deftest archive-events-filters-admin-console-event-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-organization-attribute") (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (:called? @nitrate-mock)) + (run-both! {:uri archive-uri}) + (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) - (let [[_ _ params] (:call-args @nitrate-mock)] + (let [[_ _ params] (:call-args @admin-console-mock)] (t/is (= #{"create-project" "create-organization-attribute"} (into #{} (map :name) (:events params))))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 3 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-skips-nitrate-when-no-matching-names +(t/deftest archive-events-skips-admin-console-when-no-matching-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (false? (:called? @nitrate-mock))) + (run-both! {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-admin-console-only-marks-without-uri (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {}) - (t/is (:called? @nitrate-mock)) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) (t/is (false? (:called? @http-mock))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-admin-console-only-marks-non-allowlisted (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive {}) - (t/is (false? (:called? @nitrate-mock))) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (false? (:called? @admin-console-mock))) (t/is (false? (:called? @http-mock))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-propagates-nitrate-error +(t/deftest archive-events-admin-console-error-still-drains-nexus + ;; Mirror of the nexus-throw test: an admin-console failure aborts + ;; only the admin-console job; Nexus already drained its own queue + ;; in its job. The row stays on the admin-console pending. (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call - :throw (ex-info "nitrate down" {})} + (with-mocks [admin-console-mock {:target 'app.nitrate/call + :throw (ex-info "nitrate down" {})} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive {:uri archive-uri}))) - (t/is (:called? @nitrate-mock)) - (t/is (false? (:called? @http-mock))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] - (t/is (= 1 (count rows)))))))) + (th/run-task! :audit-log-archive-admin-console {}))) + ;; Nexus already drained its queue; the admin-console failure no + ;; longer blocks it, but the row stays on the admin-console pending. + (t/is (:called? @admin-console-mock)) + (t/is (:called? @http-mock)) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 1 (count rows)))) + (t/is (= 1 (pending-count))))))) (t/deftest archive-events-uses-nexus-without-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} - nitrate-mock {:target 'app.nitrate/call :return nil}] + admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (false? (:called? @nitrate-mock))) + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {:uri archive-uri}) + (t/is (thrown? clojure.lang.ExceptionInfo + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) (t/deftest archive-events-both-flags-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil} + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive {:uri archive-uri}) - (t/is (:called? @nitrate-mock)) + (t/is (thrown? clojure.lang.ExceptionInfo + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])] + (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) -(t/deftest archive-events-encodes-db-rows-for-nitrate +(t/deftest archive-events-encodes-db-rows-for-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console} cf/config (assoc cf/config :admin-console-uri "http://ac.example/admin-console/" :admin-console-shared-key "ac-key" :nexus-shared-key "nexus-key")] - (let [nitrate-bodies (atom []) + (let [admin-console-bodies (atom []) shared-keys {:admin-console "ac-key" :nexus "nexus-key"} client (ig/init-key :app.nitrate/client {::http/client (Object.) ::setup/shared-keys shared-keys}) - ;; The archive job is a job-def: the handler closes over the + ;; Each archive job is a job-def: the handler closes over the ;; cfg it was given at init and takes a nil context, because ;; an in-process invocation has no job row to describe. - job-def (ig/init-key :app.loggers.audit.archive-task/job-def - {::db/pool (:app.db/pool th/*system*) - ::setup/shared-keys shared-keys - ::http/client (:app.http.client/client th/*system*) - :app.nitrate/client client})] + nexus-job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + {::db/pool (:app.db/pool th/*system*) + ::setup/shared-keys shared-keys + ::http/client (:app.http.client/client th/*system*) + ::mtx/metrics (th/metrics-stub) + :app.nitrate/client client}) + admin-console-job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + {::db/pool (:app.db/pool th/*system*) + ::mtx/metrics (th/metrics-stub) + :app.nitrate/client client})] (with-mocks [http-mock {:target 'app.http.client/req :return (fn [_ req & _] (when (str/includes? (str (:uri req)) "api/audit-log") - (swap! nitrate-bodies conj (:body req))) + (swap! admin-console-bodies conj (:body req))) {:status 204})}] (let [team-id (uuid/next) row (th/db-insert! :audit-log @@ -219,9 +260,13 @@ :context (db/tjson {}) :tracked-at (ct/now) :created-at (ct/now)})] - ((::jobs/handler job-def) nil {:uri archive-uri}) - (t/is (= 1 (count @nitrate-bodies))) - (let [body (json/decode (first @nitrate-bodies) :key-fn json/read-kebab-key) + (insert-pendings! (:id row)) + ;; Admin console first so its body is captured before Nexus + ;; deletes the last pending and the row. + ((::jobs/handler admin-console-job-def) nil {}) + ((::jobs/handler nexus-job-def) nil {:uri archive-uri}) + (t/is (= 1 (count @admin-console-bodies))) + (let [body (json/decode (first @admin-console-bodies) :key-fn json/read-kebab-key) event (first (:events body))] (t/is (= 1 (count (:events body)))) (t/is (= (str (:id row)) (str (:id event)))) @@ -230,14 +275,14 @@ (t/is (string? (:created-at event))) (t/is (string? (:tracked-at event))) (t/is (= (str team-id) (str (get-in event [:props :team-id]))))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))))) -(t/deftest archive-events-requires-uri-when-nexus-flag-set +(t/deftest archive-nexus-job-requires-uri-when-nexus-flag-set (with-redefs [cf/flags #{:nexus} cf/config (dissoc cf/config :audit-log-archive-uri)] (let [data (ex-data (try - (th/run-task! :audit-log-archive {}) + (th/run-task! :audit-log-archive-nexus {}) (catch Throwable cause cause)))] (t/is (= :task-not-configured (:code data)))))) @@ -247,7 +292,7 @@ member (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :profile-id (:id actor) @@ -255,16 +300,16 @@ :member-id (:id member) ;; Producers store this as a string UUID. :user-who-send-invitation (str (:id actor))}) - (th/run-task! :audit-log-archive {}) - (t/is (:called? @nitrate-mock)) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) + (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email actor) (:profile-email props))) (t/is (= (:email member) (:user-email props))) (t/is (= (:email member) (:member-email props))) (t/is (= (:email actor) (:user-who-send-invitation-email props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-enriches-soft-deleted-team-and-profile (let [actor (th/create-profile* 1 {:is-active true}) @@ -273,22 +318,22 @@ (th/db-update! :team {:deleted-at (ct/now)} {:id (:id team)}) (th/db-update! :profile {:deleted-at (ct/now)} {:id (:id other)}) (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :profile-id (:id other)}) - (th/run-task! :audit-log-archive {}) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email other) (:profile-email props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-does-not-overwrite-existing-enrich-props (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :team-name "already-set-name" @@ -300,8 +345,8 @@ :member-email "already-member@example.com" :user-who-send-invitation (str (:id actor)) :user-who-send-invitation-email "already-inviter@example.com"}) - (th/run-task! :audit-log-archive {}) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (let [props (admin-console-event-props admin-console-mock)] (t/is (= "already-set-name" (:team-name props))) (t/is (= "already-set@example.com" (:profile-email props))) (t/is (= "already-user@example.com" (:user-email props))) @@ -312,29 +357,29 @@ (t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids (let [actor (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-project" {:team-id (uuid/next) :profile-id (uuid/next) :user-id "not-a-uuid" :member-id 42}) - (th/run-task! :audit-log-archive {}) - (t/is (:called? @nitrate-mock)) - (let [props (nitrate-event-props nitrate-mock)] + (th/run-task! :audit-log-archive-admin-console {}) + (t/is (:called? @admin-console-mock)) + (let [props (admin-console-event-props admin-console-mock)] (t/is (nil? (:team-name props))) (t/is (nil? (:profile-email props))) (t/is (nil? (:user-email props))) (t/is (nil? (:member-email props))) (t/is (= "not-a-uuid" (:user-id props))) (t/is (= 42 (:member-id props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 1 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-events-forwards-export-job-not-legacy-export (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] - (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id actor) "create-export-binfile-job" {:team-id (:id team) :job-id (uuid/next) @@ -342,20 +387,336 @@ :export-type "detach-libraries"}) (insert-audit-row! (:id actor) "export-binfile" {:file-id (uuid/next)}) - (th/run-task! :audit-log-archive {}) - (let [[_ _ params] (:call-args @nitrate-mock) + (th/run-task! :audit-log-archive-admin-console {}) + (let [[_ _ params] (:call-args @admin-console-mock) events (:events params) props (:props (first events))] (t/is (= ["create-export-binfile-job"] (mapv :name events))) (t/is (= (:id team) (:team-id props))) (t/is (= (:name team) (:team-name props)))) - (let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])] - (t/is (= 2 (count rows)))))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) (t/deftest archive-params-accept-empty-cron-props ;; Cron always submits (or props {}) — system deps live on the ;; job-def cfg, not in params. Requiring them here blocks every tick. - (let [valid? (sm/validator archive/schema:audit-log-archive-params)] - (t/is (true? (valid? {}))) - (t/is (true? (valid? {:enabled true}))) - (t/is (true? (valid? {:uri (u/uri archive-uri)}))))) + ;; Both schemas are closed: a stale manual invocation must fail + ;; validation loudly instead of skipping silently. + (let [nexus-valid? (sm/validator archive/schema:audit-log-archive-nexus-params) + admin-console-valid? (sm/validator archive/schema:audit-log-archive-admin-console-params)] + (t/is (true? (nexus-valid? {}))) + (t/is (false? (nexus-valid? {:enabled true}))) + (t/is (true? (nexus-valid? {:uri (u/uri archive-uri)}))) + (t/is (true? (admin-console-valid? {}))) + (t/is (false? (admin-console-valid? {:enabled true}))) + (t/is (false? (admin-console-valid? {:uri (u/uri archive-uri)}))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; METRICS SMOKE TEST +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; The job-def cfg of these tests carries an isolated metrics registry +;; (pattern from `jobs-metrics-test`), so assertions read exact values +;; instead of the shared test-system registry. `th/run-task!` would use +;; the latter and its counts would leak across tests. +(def ^:private archive-metric-ids + [:audit-archive-events :audit-archive-discarded]) + +(defn- make-archive-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics archive-metric-ids)})) + +(defn- invoke-nexus! + "Invoke the nexus archive handler in-process with an isolated + metrics registry. The handler takes a nil context because there is + no job row to describe (same as `th/run-task!`)." + [metrics params] + (let [job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + {::db/pool (:app.db/pool th/*system*) + ::setup/shared-keys {:admin-console "ac-key" :nexus "nexus-key"} + ::http/client (:app.http.client/client th/*system*) + ::mtx/metrics metrics})] + ((::jobs/handler job-def) nil params))) + +(defn- invoke-admin-console! + "Invoke the admin-console archive handler in-process with an + isolated metrics registry. The admin-console client is the (nil in + tests) system one: tests mock `app.nitrate/call`, so it is never + dereferenced." + [metrics] + (let [job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + {::db/pool (:app.db/pool th/*system*) + :app.nitrate/client (:app.nitrate/client th/*system*) + ::mtx/metrics metrics})] + ((::jobs/handler job-def) nil {}))) + +(defn- invoke-both! + "Invoke both archive handlers sharing one isolated metrics + registry, Nexus first (the historical single-run order)." + [metrics params] + (invoke-nexus! metrics params) + (invoke-admin-console! metrics)) + +(defn- counter-value + [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(t/deftest archive-records-metrics-on-success + ;; Smoke test: the run records its domain metrics into the wired + ;; registry (isolated per test, pattern from `jobs-metrics-test`). + ;; Logging itself is not asserted: `dbg` summaries run below the + ;; test log level and failure `warn`s only repeat the outcome the + ;; metrics already prove. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "create-project") + (insert-audit-row! (:id prof) "unrelated-event") + (invoke-both! metrics {:uri archive-uri}) + (t/is (= 2.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 0 (pending-count))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PENDING-QUEUE TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- full-event + "A complete event for submit*, which (unlike submit) assumes every + required field is filled." + [profile-id] + {:name "create-file" :type "action" :source "backend" + :profile-id profile-id :ip-addr "127.0.0.1" + :props {} :context {} + :created-at (ct/now) :tracked-at (ct/now)}) + +(t/deftest archive-submit-creates-pendings-for-enabled-archivers + ;; The producer hook: a row is born pending for the enabled + ;; archivers, in the same transaction as the row itself. Goes + ;; through submit* because the suite stubs submit itself (see + ;; helpers/init-config). + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :nexus :admin-console}] + (audit/submit* {::db/pool (:app.db/pool th/*system*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 2 (pending-count)))))) + +(t/deftest archive-submit-single-flag-creates-one-pending + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :nexus}] + (audit/submit* {::db/pool (:app.db/pool th/*system*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count)))))) + +(t/deftest archive-submit-rollback-keeps-no-pendings + ;; A rollback takes the row and its queue together: a row never + ;; outlives its pendings, and no phantom pending pins nothing. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :nexus :admin-console}] + (db/tx-run! {::db/pool (:app.db/pool th/*system*) + ::db/rollback true} + (fn [cfg] + (audit/submit* cfg (full-event (:id prof))) + (t/is (= 1 (count (db/exec! cfg ["select * from audit_log"])))) + (t/is (= 2 (count (db/exec! cfg ["select * from audit_log_pending"])))))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))))) + +(t/deftest archive-keeps-row-until-last-archiver-done + ;; Nexus refuses the chunk: the nexus job aborts (failed count + ;; recorded, isolated registry) while the admin-console job still + ;; drains its own queue. The row and the nexus pending stay; once + ;; nexus accepts, everything is gone. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (insert-audit-row! (:id prof) "create-project") + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 500}}] + (let [err (try + (invoke-nexus! metrics {:uri archive-uri}) + nil + (catch clojure.lang.ExceptionInfo cause cause))] + (t/is (= :archive-chunk-refused (:code (ex-data err))))) + (invoke-admin-console! metrics) + (t/is (:called? @admin-console-mock)) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count)))) + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (run-both! {:uri archive-uri}) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))))))) + +(t/deftest archive-sweeps-orphan-rows + ;; A row with no pendings (old archived leftover, or born while no + ;; archiver was on) is deleted without any send. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + ;; Raw insert, no pendings on purpose. + (th/db-insert! :audit-log + {:id (uuid/next) + :name "create-project" + :type "action" + :source "backend" + :profile-id (:id prof) + :ip-addr (db/inet "127.0.0.1") + :props (db/tjson {}) + :context (db/tjson {}) + :tracked-at (ct/now) + :created-at (ct/now)}) + (run-both! {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) + (t/is (false? (:called? @http-mock))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))) + +(t/deftest archive-nexus-transport-failure-records-failed + ;; A transport exception (timeout, DNS, refused) counts the events + ;; as failed and aborts the nexus job: the chunk rolls back and + ;; the rows stay for the next tick. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req + :throw (ex-info "timeout" {})}] + (insert-audit-row! (:id prof) "create-project") + (t/is (thrown? clojure.lang.ExceptionInfo + (invoke-nexus! metrics {:uri archive-uri}))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count))))))) + +(t/deftest archive-sends-chunks-in-chronological-order + ;; Nexus numbers by arrival for order-sensitive consumers, so + ;; multi-chunk drains must arrive oldest-first no matter how the + ;; queue is scanned. + (let [prof (th/create-profile* 1 {:is-active true}) + base (ct/now) + bodies (atom [])] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req + :return (fn [_ params & _] + (swap! bodies conj (:body params)) + {:status 204})}] + (doseq [i (range 130)] + (let [row (insert-audit-row! (:id prof) (str "event-" i))] + (th/db-update! :audit-log + {:created-at (.plusSeconds ^java.time.Instant base (long i))} + {:id (:id row)}))) + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (let [received (mapcat #(:events (transit/decode %)) @bodies) + times (map :created-at received)] + (t/is (= 130 (count received))) + (t/is (= (sort times) times)) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))))) + +(t/deftest archive-reaps-disabled-archiver-pendings + ;; R3 reap by its real path: an admin-console pending born while + ;; admin-console was on is dropped once it is off; nexus still + ;; archives its queue and the row is gone with nothing stuck. The + ;; dropped pending counts as discarded (isolated registry). + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} + admin-console-mock {:target 'app.nitrate/call :return nil}] + (let [row (insert-audit-row! (:id prof) "create-project")] + ;; Admin console was on at insert time: both pendings on purpose. + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + VALUES (?::uuid, 2) + ON CONFLICT DO NOTHING" + (:id row)]) + (t/is (= 2 (pending-count))) + (invoke-nexus! metrics {:uri archive-uri}) + (t/is (:called? @http-mock)) + (t/is (false? (:called? @admin-console-mock))) + (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["reaped"]))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))))))) + +(t/deftest archive-sweeps-rows-born-with-no-archiver-on + ;; Accepted R3 consequence by its real path (not a hand-made + ;; orphan): ingested with no send flag on, the row is swept + ;; unarchived once the task runs, with zero sends. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:audit-log}] + (audit/submit* {::db/pool (:app.db/pool th/*system*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))) + (with-redefs [cf/flags #{:audit-log :nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}}] + (invoke-nexus! metrics {:uri archive-uri}) + (t/is (false? (:called? @http-mock))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))) + (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["swept"]))) + (t/is (= 0.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))))))) + +(t/deftest archive-nexus-throw-still-drains-admin-console + ;; Consumer independence, now structural: a Nexus transport throw + ;; aborts only the nexus job, and the admin-console job still drains + ;; its own queue. Nexus keeps its pending (so the row stays); the + ;; admin console completes its own queue. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req + :throw (ex-info "timeout" {})}] + (insert-audit-row! (:id prof) "create-project") + (t/is (thrown? clojure.lang.ExceptionInfo + (invoke-nexus! metrics {:uri archive-uri}))) + (invoke-admin-console! metrics) + (t/is (:called? @admin-console-mock)) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count))))))) + +(t/deftest archive-reap-then-sweeps-without-sends + ;; End to end of the reap path: a row born with only an + ;; admin-console pending is reaped once :admin-console goes off, + ;; then swept with zero sends on either destination. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:admin-console}] + (insert-audit-row! (:id prof) "create-project") + (t/is (= 1 (pending-count)))) + (with-redefs [cf/flags #{:nexus}] + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} + admin-console-mock {:target 'app.nitrate/call :return nil}] + (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (t/is (false? (:called? @http-mock))) + (t/is (false? (:called? @admin-console-mock))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))))))) + +(t/deftest archive-fully-filtered-admin-console-chunk-counts-skipped + ;; An admin-console chunk with every event outside the allowlist + ;; never calls ingest, but still counts its rows as skipped and + ;; completes its queue. + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-archive-metrics)] + (with-redefs [cf/flags #{:nexus :admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "unrelated-event") + (invoke-both! metrics {:uri archive-uri}) + (t/is (false? (:called? @admin-console-mock))) + (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count))))))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 7c99c0093c..6080c483cf 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -14,13 +14,24 @@ [app.db :as db] [app.jobs :as-alias jobs] [app.loggers.audit :as audit] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] + [app.rpc.commands.audit :as cmd-audit] [app.util.services :as sv] [backend-tests.helpers :as th] + [clojure.string :as str] [clojure.test :as t] + [integrant.core :as ig] [mockery.core :refer [with-mocks]] - [yetti.request])) + [yetti.request]) + (:import + io.prometheus.client.Collector$MetricFamilySamples + io.prometheus.client.CollectorRegistry + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -97,7 +108,7 @@ (assoc base :app.http/auth-key-id :nexus))))))) (t/deftest push-events-1 - (with-redefs [app.config/flags #{:audit-log}] + (with-redefs [app.config/flags #{:audit-log :nexus}] (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) proj-id (:default-project-id prof) @@ -127,7 +138,9 @@ (t/is (= 1 (count rows))) (t/is (= (:id prof) (:profile-id row))) (t/is (= "navigate" (:name row))) - (t/is (= "frontend" (:source row))))))) + (t/is (= "frontend" (:source row))) + ;; The row is born pending for the enabled archiver. + (t/is (= 1 (count (th/db-exec! ["select * from audit_log_pending"])))))))) (t/deftest push-events-2 (with-redefs [app.config/flags #{:audit-log}] @@ -228,36 +241,41 @@ (t/deftest push-events-both-flags-creates-two-rows ;; When both :audit-log and :telemetry flags are active, two rows ;; should be stored: one full audit entry and one telemetry entry. - (with-redefs [cf/flags #{:audit-log :telemetry}] - (let [prof (th/create-profile* 1 {:is-active true}) - params {::th/type :push-audit-events - ::rpc/profile-id (:id prof) - :events [{:name "navigate" - :props {:route "dashboard"} - :context {:browser "Chrome" - :version "2.0.0" - :initiator "app"} - :timestamp (ct/now) - :type "action"}]} - params (with-meta params - {:app.http/request http-request}) - out (th/command! params)] + ;; The profile is created before enabling :admin-console: team + ;; bootstrap calls the real nitrate client, which is unconfigured + ;; in tests. + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}] + (let [params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :context {:browser "Chrome" + :version "2.0.0" + :initiator "app"} + :timestamp (ct/now) + :type "action"}]} + params (with-meta params + {:app.http/request http-request}) + out (th/command! params)] - (t/is (nil? (:error out))) + (t/is (nil? (:error out))) - (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) - (mapv decode-row))] - (t/is (= 2 (count rows))) - ;; First row: full audit-log entry - (t/is (= "frontend" (:source row1))) - (t/is (contains? (:props row1) :route)) - (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) - ;; Second row: telemetry entry - (t/is (= "telemetry:frontend" (:source row2))) - (t/is (= "0.0.0.0" (str (:ip-addr row2)))) - (let [day-now (ct/truncate (ct/now) :days)] - (t/is (= day-now (:created-at row2))) - (t/is (= day-now (:tracked-at row2)))))))) + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + ;; First row: full audit-log entry + (t/is (= "frontend" (:source row1))) + (t/is (contains? (:props row1) :route)) + (t/is (not= "0.0.0.0" (str (:ip-addr row1)))) + ;; Second row: telemetry entry + (t/is (= "telemetry:frontend" (:source row2))) + (t/is (= "0.0.0.0" (str (:ip-addr row2)))) + (let [day-now (ct/truncate (ct/now) :days)] + (t/is (= day-now (:created-at row2))) + (t/is (= day-now (:tracked-at row2)))) + ;; Both rows are born pending for the enabled archivers. + (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; BACKEND PROCESS-EVENT PATH (RPC commands) @@ -857,3 +875,89 @@ (t/is (= (:id caller) (:profile-id (prepare-event (:id caller) result))) (str "override " (pr-str bad) " must fall back to the caller")))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; INGEST METRICS (bulk frontend path) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; handle-events resolves its metrics from the cfg (same pattern as +;; the jobs substrate), so these tests drive it directly with an +;; isolated registry instead of the shared test-system one. +(defn- make-ingest-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics [:audit-ingest-events])})) + +(defn- ingest-counter + [metrics kind outcome] + (let [collector (mtx/get-collector metrics :audit-ingest-events) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String [kind outcome]))] + (.get ^Counter$Child child))) + +(defn- counter-label-sets + "Exact label sets present for the ingest counter, read off the + registry so phantom series cannot hide. Matches the family by + prefix (Prometheus strips the `_total` suffix from the family + name) and only counts `_total` samples, not `_created` ones." + [metrics] + (let [full (get main/default-metrics :audit-ingest-events) + base (str/replace (::mdef/name full) #"_total$" "")] + (->> (enumeration-seq + (.metricFamilySamples ^CollectorRegistry (mtx/get-registry metrics))) + (filter (fn [^Collector$MetricFamilySamples family] + (str/starts-with? (.-name family) base))) + (mapcat (fn [^Collector$MetricFamilySamples family] + (.samples family))) + (filter (fn [sample] (str/ends-with? (.-name sample) "_total"))) + (into #{} (map (fn [sample] (vec (.-labelValues sample)))))))) + +(defn- push-params + [profile-id] + (with-meta {::rpc/profile-id profile-id + ::rpc/request-at (ct/now) + :events [{:name "navigate" :type "action" + :props {:route "dashboard"} + :timestamp (ct/now)}]} + ;; A map request (like th/command! builds): the reify IRequest + ;; stub only serves the IP headers and blows up on x-client. + {:app.http/request (th/make-dummy-request)})) + +(t/deftest push-handle-events-records-inserted + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-ingest-metrics)] + (with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}] + (@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics} + (push-params (:id prof))) + (t/is (= 1.0 (ingest-counter metrics "full" "inserted"))) + (t/is (= 1.0 (ingest-counter metrics "shadow" "inserted"))) + (t/is (= 2 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))) + +(t/deftest push-handle-events-records-failed-on-error + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-ingest-metrics)] + (with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}] + (with-mocks [insert-mock {:target 'app.db/insert-many! + :throw (ex-info "boom" {})}] + (t/is (thrown? clojure.lang.ExceptionInfo + (@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics} + (push-params (:id prof))))) + (t/is (= 1.0 (ingest-counter metrics "full" "failed"))) + (t/is (= 1.0 (ingest-counter metrics "shadow" "failed"))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"])))))))) + +(t/deftest push-handle-events-single-face-emits-no-empty-series + ;; With only :audit-log on, exactly one ingest series exists: the + ;; pos? guard in record-ingested emits no phantom shadow series. + ;; (No send flags either, so R3 leaves the row pending-less.) + (let [prof (th/create-profile* 1 {:is-active true}) + metrics (make-ingest-metrics)] + (with-redefs [cf/flags #{:audit-log}] + (@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics} + (push-params (:id prof))) + (t/is (= 1.0 (ingest-counter metrics "full" "inserted"))) + (t/is (= #{["full" "inserted"]} (counter-label-sets metrics))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"]))))))) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 79f29f335b..4585de8c98 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -11,6 +11,7 @@ [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] + [app.loggers.audit.archive-task :as at] [app.tasks.telemetry :as telemetry] [app.util.json :as json] [backend-tests.helpers :as th] @@ -999,3 +1000,45 @@ (th/run-task! :telemetry {:send? true :enabled? true}) (t/is (= 1 @send-count)) ;; still 1, not 2 (t/is (= 0 (count-telemetry-rows))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PENDING-QUEUE REGRESSION (telemetry deletes) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- insert-pending-pair! + "Queue the row for both archivers (from the archiver constants, not + literals): the delete paths must face pending-ful rows." + [row-id] + (doseq [archiver [at/archiver-nexus at/archiver-admin-console]] + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + VALUES (?::uuid, ?::smallint) + ON CONFLICT DO NOTHING" + row-id archiver]))) + +(defn- count-pendings [] + (-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log_pending"]) + :cnt + long)) + +(t/deftest test-delete-sent-events-removes-pendings-first + ;; Regression: telemetry rows carry pendings for both archivers, so + ;; deleting the row must delete its pendings first, or the FK + ;; rejects the delete and the batch is sent again next tick. + (let [row (insert-telemetry-row! "navigate")] + (insert-pending-pair! (:id row)) + (t/is (= 2 (count-pendings))) + (db/tx-run! th/*pool* + (fn [{:keys [::db/conn]}] + (@#'telemetry/delete-sent-events conn [(:id row)]))) + (t/is (= 0 (count-telemetry-rows))) + (t/is (= 0 (count-pendings))))) + +(t/deftest test-gc-events-removes-pendings-first + ;; Same guarantee for the >7-days purge. + (let [past (ct/in-past {:days 8}) + row (insert-telemetry-row! "navigate" {:created-at past :tracked-at past})] + (insert-pending-pair! (:id row)) + (t/is (= 2 (count-pendings))) + (@#'telemetry/gc-events {::db/conn th/*pool*}) + (t/is (= 0 (count-telemetry-rows))) + (t/is (= 0 (count-pendings))))) From be2e117f670fdc3fe440e55bea541df28f1c76d9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 19 Oct 2026 23:25:44 +0200 Subject: [PATCH 117/325] :recycle: Rename audit-log archivers to consumers and use base Nexus URI (#12459) * :recycle: Rename audit-log archivers to consumers The pending-queue owners are no longer only external archives: a local, non-archiving drain is about to join them, so "archiver" no longer fits. Rename the column, identifiers, jobs, metrics and docs to "consumer" in one release; migration 0157 never reached production, so a single RENAME COLUMN is enough, with no backfill. AI-assisted-by: muse-spark-1.3-contributor * :recycle: Use base Nexus URI with appended ingest path Mirror the admin-console setup: PENPOT_NEXUS_URI holds the base URI and the consumer appends api/audit itself, instead of a full endpoint URI. Keeps the explicit :uri job param as override and the :task-not-configured guard when no base is set. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/audit-log.md | 26 +-- .serena/memories/prod-infra/core.md | 2 +- backend/scripts/_env | 2 +- backend/src/app/config.clj | 2 +- backend/src/app/loggers/audit.clj | 8 +- .../{archive_task.clj => consumer_task.clj} | 217 +++++++++--------- backend/src/app/main.clj | 26 +-- .../sql/0157-add-audit-log-pending-table.sql | 18 +- backend/src/app/rpc/commands/audit.clj | 6 +- backend/src/app/tasks/telemetry.clj | 6 +- ...j => loggers_audit_consumer_task_test.clj} | 198 ++++++++-------- backend/test/backend_tests/rpc_audit_test.clj | 4 +- .../backend_tests/tasks_telemetry_test.clj | 12 +- 13 files changed, 276 insertions(+), 251 deletions(-) rename backend/src/app/loggers/audit/{archive_task.clj => consumer_task.clj} (77%) rename backend/test/backend_tests/{loggers_audit_archive_task_test.clj => loggers_audit_consumer_task_test.clj} (82%) diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index bc33a4a190..68d4bcc021 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -45,7 +45,7 @@ Rule of thumb: if you need to know for sure "did this user do X?", look at `sour ## 3. The `audit_log` table: a buffer, not a store -The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until every active archiver (Nexus, admin console) confirms them, and whoever finishes last deletes the row. +The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until every active consumer (Nexus, admin console) confirms them, and whoever finishes last deletes the row. Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table.sql` and later ones): @@ -57,16 +57,16 @@ Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table - `source` text: `backend` / `frontend` for full rows, `telemetry:backend` / `telemetry:frontend` for anonymized copies (see section 10). - `ip_addr` inet: real IP (or `0.0.0.0` on telemetry rows). - `props` / `context` jsonb holding transit-encoded maps: action data and metadata (versions, browser, request-id...). -- No `archived_at`: the `audit_log_pending` queue (migration `0157`, archiver `1` = Nexus, `2` = admin console, ids never reused) tracks what is left. Every record is born pending only for the archivers enabled at insert time (never a fixed both): cheaper on single-archiver hosts and true to the design doc. Consequence, accepted on purpose: rows born with no send flag on get no pendings, so enabling archiving later does not pick them up — the sweep deletes them unarchived. +- No `archived_at`: the `audit_log_pending` queue (migration `0157`, consumer `1` = Nexus, `2` = admin console, ids never reused) tracks what is left. Every record is born pending only for the consumers enabled at insert time (never a fixed both): cheaper on single-consumer hosts and true to the design doc. Consequence, accepted on purpose: rows born with no send flag on get no pendings, so enabling archiving later does not pick them up — the sweep deletes them unarchived. Indexes (each consumer has its own, so a slow consumer never blocks the others): -- `audit_log_pending` PK `(archiver, audit_log_id)`: each archiver owns its queue at constant cost, even with millions pending. -- `audit_log (created_at, id)` (migration `0157`): the chronological drain order (`ORDER BY created_at ASC, id ASC`; same total order for both archivers, so concurrent drains cannot deadlock). +- `audit_log_pending` PK `(consumer, audit_log_id)`: each consumer owns its queue at constant cost, even with millions pending. +- `audit_log (created_at, id)` (migration `0157`): the chronological drain order (`ORDER BY created_at ASC, id ASC`; same total order for both consumers, so concurrent drains cannot deadlock). - `audit_log_pending (audit_log_id)`: serves the "is anything still pending?" check of the final delete. - `(source, created_at)`: used by the telemetry scan. -Life of a row: `INSERT` (row plus one pending per enabled archiver, same transaction; a rollback takes both) → (every 5 min, Nexus at second 0 and admin console at second 30) each archive job sends its own 128-row batches oldest-first and deletes its pendings in the same transaction → whoever deletes the last pending deletes the row. A batch the destination refuses rolls back and stays for the next tick. Pendings of archivers that are off are reaped at the start of each job run (covers later deactivations), and orphan rows are swept at the end of each run, so there is no separate GC task. The FK is plain `DEFERRABLE` (never `INITIALLY DEFERRED`): every deleter removes pendings before rows, including the telemetry task's own deletes. +Life of a row: `INSERT` (row plus one pending per enabled consumer, same transaction; a rollback takes both) → (every 5 min, Nexus at second 0 and admin console at second 30) each archive job sends its own 128-row batches oldest-first and deletes its pendings in the same transaction → whoever deletes the last pending deletes the row. A batch the destination refuses rolls back and stays for the next tick. Pendings of consumers that are off are reaped at the start of each job run (covers later deactivations), and orphan rows are swept at the end of each run, so there is no separate GC task. The FK is plain `DEFERRABLE` (never `INITIALLY DEFERRED`): every deleter removes pendings before rows, including the telemetry task's own deletes. ## 4. Anatomy of an event @@ -277,7 +277,7 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert ### How it works today -Every 5 min each archive job drains its own queue (`:audit-log-archive-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-archive-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:archive-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-archiver` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). +Every 5 min each archive job drains its own queue (`:audit-log-consumer-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-consumer-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:consumer-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-consumer` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). @@ -285,7 +285,7 @@ On the other side, Nexus checks `origin` against its allowlist (403 if not allow Concurrent jobs are safe: both lock rows in `created_at ASC, id ASC` order (one total order, deliberately blocking — no `SKIP LOCKED`, so archive arrival stays chronological), so the second waits for the first to commit and then sees whether it was last. Only acknowledged work deletes pendings; an admin-console failure never blocks the Nexus send of the same rows (each job owns its queue). -Observability (`backend/src/app/loggers/audit/archive_task.clj`, definitions in `backend/src/app/main.clj:default-metrics`): one `dbg` summary per job run (`:archiver` label, sent/skipped/chunks, reaped disabled pendings, swept orphans, `:elapsed` as `"1.5s"` via `ct/format-duration`, frozen in a strict binding first because log props evaluate lazily in the logger thread), one `dbg` per admin-console send call (`ready` with totals, `sent`/`failed` with counts), plus `dbg` when the queue is empty or the job's send flag is off. Queue depth is Grafana's business: it queries the database directly, so the run performs no `GROUP BY` for the logs. No `warn` before a throw: the runner reports the aborted job (refusal details travel in the error data). Metrics, all recorded with bounded labels only: `penpot_audit_archive_events_total{ destination = nexus|admin-console, outcome = archived|failed|skipped }` (`skipped` = filtered by the admin-console allowlist) and `penpot_audit_archive_discarded_total{ reason = reaped|swept }` (entries discarded without archiving: `reaped` counts pendings dropped for disabled archivers, `swept` counts rows deleted while nobody waited for them — the silent R3 loss, now dashboard-visible instead of log-only). Timing needs no custom histogram: the jobs substrate already records execution time per job (`jobs-execution-timing`, split by job name), so each archive job is timed as itself. No queue-depth gauge: pending depth is read off the replica database directly. The bulk frontend ingest counts `penpot_audit_ingest_events_total{ kind = full|shadow, outcome = inserted|failed }` (both faces share one transaction, so a failure drops the batch on both). Archive success counts are recorded by `run-single-archiver` on the drain totals: a chunk that returns normally committed its transaction (a rollback always throws instead), so they cannot inflate. Logs never carry event data (`props`, `context`, `profile-id`, `ip-addr`, `team-name`, emails): only counts, statuses and the uri host. +Observability (`backend/src/app/loggers/audit/consumer_task.clj`, definitions in `backend/src/app/main.clj:default-metrics`): one `dbg` summary per job run (`:consumer` label, sent/skipped/chunks, reaped disabled pendings, swept orphans, `:elapsed` as `"1.5s"` via `ct/format-duration`, frozen in a strict binding first because log props evaluate lazily in the logger thread), one `dbg` per admin-console send call (`ready` with totals, `sent`/`failed` with counts), plus `dbg` when the queue is empty or the job's send flag is off. Queue depth is Grafana's business: it queries the database directly, so the run performs no `GROUP BY` for the logs. No `warn` before a throw: the runner reports the aborted job (refusal details travel in the error data). Metrics, all recorded with bounded labels only: `penpot_audit_consumer_events_total{ destination = nexus|admin-console, outcome = archived|failed|skipped }` (`skipped` = filtered by the admin-console allowlist) and `penpot_audit_consumer_discarded_total{ reason = reaped|swept }` (entries discarded without archiving: `reaped` counts pendings dropped for disabled consumers, `swept` counts rows deleted while nobody waited for them — the silent R3 loss, now dashboard-visible instead of log-only). Timing needs no custom histogram: the jobs substrate already records execution time per job (`jobs-execution-timing`, split by job name), so each archive job is timed as itself. No queue-depth gauge: pending depth is read off the replica database directly. The bulk frontend ingest counts `penpot_audit_ingest_events_total{ kind = full|shadow, outcome = inserted|failed }` (both faces share one transaction, so a failure drops the batch on both). Archive success counts are recorded by `run-single-consumer` on the drain totals: a chunk that returns normally committed its transaction (a rollback always throws instead), so they cannot inflate. Logs never carry event data (`props`, `context`, `profile-id`, `ip-addr`, `team-name`, emails): only counts, statuses and the uri host. ### How Nexus consumes the data (and forwards to PostHog) @@ -297,9 +297,9 @@ Stored rows are not the end: a consumer pipeline reads them back in sequence ord So the full journey is: Penpot buffer → Nexus `audit_log` (via either archive cron or the telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. -### Admin console: the second archiver +### Admin console: the second consumer -Penpot → admin-console archiving lives in its own job (`:audit-log-archive-admin-console`, archiver `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): +Penpot → admin-console archiving lives in its own job (`:audit-log-consumer-admin-console`, consumer `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): - **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user. - **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b). @@ -312,13 +312,13 @@ Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=ena | Flag | What it does | |---|---| | `:audit-log` | Stores full rows; turns on `wrap-audit` and frontend ingest | -| `:nexus` | Send flag of the Nexus archive job `:audit-log-archive-nexus` (cron `0 */5 * * * ?`; needs `:audit-log-archive-uri`) | -| `:admin-console` | Send flag of the admin-console archive job `:audit-log-archive-admin-console` (cron `30 */5 * * * ?`, 30s after Nexus); also the module flag for the admin-console integration | +| `:nexus` | Send flag of the Nexus archive job `:audit-log-consumer-nexus` (cron `0 */5 * * * ?`; needs `:nexus-uri` (ingest path `api/audit` appended by the job)) | +| `:admin-console` | Send flag of the admin-console archive job `:audit-log-consumer-admin-console` (cron `30 */5 * * * ?`, 30s after Nexus); also the module flag for the admin-console integration | | `:audit-log-logger` | Structured `app.audit` log per event | | `:error-reporting` | Starts the error reporters (off by default); database reporter on flag alone, Mattermost on flag plus webhook URL | | `:telemetry` | Stores shadow rows and turns on shipping (auto with `:telemetry-enabled`) | -Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri` (default `https://telemetry.penpot.app/`), `:nexus-shared-key` / instance secret, `:public-uri` (sent as `origin` to Nexus). +Config (`backend/src/app/config.clj`): `:nexus-uri`, `:telemetry-uri` (default `https://telemetry.penpot.app/`), `:nexus-shared-key` / instance secret, `:public-uri` (sent as `origin` to Nexus). ## 14. Practical guide: add or change events @@ -337,6 +337,6 @@ Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri - Penpot frontend producer: `frontend/src/app/main/data/event.cljs` (collector, buffer, send), `frontend/src/app/main/repo.cljs` + `frontend/src/app/util/http.cljs` (headers), `frontend/src/app/main/data/nitrate.cljs` (Nitrate membership events). - Admin-console producer (private repo, no paths cited here): server-side organization/subscription events through the management `push-audit-events`, browser-side UI events through the public `push-audit-events`. - Consumers: `backend/src/app/loggers/webhooks.clj`, `backend/src/app/loggers/database.clj`, `backend/src/app/loggers/mattermost.clj`, `backend/src/app/tasks/telemetry.clj`. -- Archive (two jobs sharing one namespace + pending queue, no GC): `backend/src/app/loggers/audit/archive_task.clj` (`:audit-log-archive-nexus` / `:audit-log-archive-admin-console` job-defs, shared drain/reap/sweep helpers), producer hook in `backend/src/app/loggers/audit.clj` (`append-audit-entry`, single `queue-pendings` helper) plus the bulk ingest in `backend/src/app/rpc/commands/audit.clj` (`handle-events`), metrics/cron wiring in `backend/src/app/main.clj` (one cron entry per job, Nexus at second 0 and admin console at second 30, each gated by its own send flag), single migration `0157` (queue, drop of `archived_at`, covering `(created_at, id)` index for the chronological scan; no backfill — pre-existing rows are swept unsent, an accepted loss). +- Archive (two jobs sharing one namespace + pending queue, no GC): `backend/src/app/loggers/audit/consumer_task.clj` (`:audit-log-consumer-nexus` / `:audit-log-consumer-admin-console` job-defs, shared drain/reap/sweep helpers), producer hook in `backend/src/app/loggers/audit.clj` (`append-audit-entry`, single `queue-pendings` helper) plus the bulk ingest in `backend/src/app/rpc/commands/audit.clj` (`handle-events`), metrics/cron wiring in `backend/src/app/main.clj` (one cron entry per job, Nexus at second 0 and admin console at second 30, each gated by its own send flag), single migration `0157` (queue, drop of `archived_at`, covering `(created_at, id)` index for the chronological scan; no backfill — pre-existing rows are swept unsent, an accepted loss). - Nexus (private repo, no paths cited here): archive ingest, ordered consumers (local profile mirror, PostHog SaaS events, PostHog telemetry), telemetry endpoint, analytics views. - Tests: `backend_tests/rpc-audit-test.clj`, `rpc-team-test/accept-organization-invitation-audit-event`. diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index 2b3509a33f..5cd8bb707d 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -22,7 +22,7 @@ Postgres row locking is the only correctness primitive: `job` claims via `FOR UP Two known race patterns survive multi-backend operation: -- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `storage-pending-gc`, `jobs-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-archive` (depends on archive target idempotency). +- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `storage-pending-gc`, `jobs-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-consumer-nexus` / `:audit-log-consumer-admin-console` (depend on archive target idempotency). - **`jobs/submit ::dedupe true`** does a non-atomic `DELETE` then `INSERT`. Concurrent cross-backend submits can both bypass the `DELETE` (each sees the other's uncommitted insert as absent) and end up with duplicate `'new'` rows. Each row claims and runs once independently, so the underlying work is fine; the "at most one pending" guarantee weakens. Penpot in production lives with both: horizontal-scale deployments accept "exactly-once" as "essentially-once for idempotent operations." Devenv parallel instances handle it by running workers only on ws0 (see `mem:devenv/core`). diff --git a/backend/scripts/_env b/backend/scripts/_env index 4f7c5655bf..f777d16a8a 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -68,7 +68,7 @@ export PENPOT_FLAGS="\ # Uncomment for nexus integration testing # export PENPOT_FLAGS="$PENPOT_FLAGS enable-nexus"; -# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; +# export PENPOT_NEXUS_URI="http://localhost:6070/"; # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 297d3de65f..33fde1fd5f 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -211,7 +211,7 @@ [:rpc-rlimit-config {:optional true} ::fs/path] [:rpc-climit-config {:optional true} ::fs/path] - [:audit-log-archive-uri {:optional true} :string] + [:nexus-uri {:optional true} ::sm/uri] [:audit-log-http-handler-concurrency {:optional true} ::sm/int] [:default-executor-parallelism {:optional true} ::sm/int] ;; REVIEW diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 619c367a49..430d369343 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -21,7 +21,7 @@ [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.jobs :as jobs] - [app.loggers.audit.archive-task :as archive] + [app.loggers.audit.consumer-task :as consumer] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] @@ -244,10 +244,10 @@ (update :ip-addr db/inet)) params (select-keys params event-keys) row (db/insert! cfg :audit-log params)] - ;; The archive queue follows the row in the same transaction: one - ;; pending per enabled archiver. A rollback takes both on its way + ;; The consumer queue follows the row in the same transaction: one + ;; pending per enabled consumer. A rollback takes both on its way ;; out, so a row never outlives its queue. - (archive/queue-pendings cfg [(:id row)]) + (consumer/queue-pendings cfg [(:id row)]) row)) (def ^:private xf:filter-telemetry-props diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/consumer_task.clj similarity index 77% rename from backend/src/app/loggers/audit/archive_task.clj rename to backend/src/app/loggers/audit/consumer_task.clj index a4eb14e5e6..6c4986eed5 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.loggers.audit.archive-task +(ns app.loggers.audit.consumer-task (:require [app.common.exceptions :as ex] [app.common.logging :as l] @@ -27,59 +27,59 @@ ;; This task drains the audit log buffer into the long-term archives. ;; ;; Two separate jobs share this namespace and the queue helpers, one -;; per destination: `:audit-log-archive-nexus` and -;; `:audit-log-archive-admin-console`. Each job drains only its own queue +;; per destination: `:audit-log-consumer-nexus` and +;; `:audit-log-consumer-admin-console`. Each job drains only its own queue ;; and deletes its pendings on completion; whoever deletes the last ;; pending also deletes the record ("last one out turns off the ;; light"), so no separate GC task is needed. Housekeeping (reaping ;; disabled pendings, sweeping orphans) lives in both jobs on purpose: ;; only the enabled job runs, so it must clean up for the rest. -;; Archiver ids are fixed constants, never reuse one: retired +;; Consumer ids are fixed constants, never reuse one: retired ;; pendings are reaped by id, so a reused id would inherit the ;; previous owner's queue. -(def archiver-nexus 1) -(def archiver-admin-console 2) +(def consumer-nexus 1) +(def consumer-admin-console 2) -(def ^:private archivers - [{:id archiver-nexus :flag :nexus :label "nexus"} - {:id archiver-admin-console :flag :admin-console :label "admin-console"}]) +(def ^:private consumers + [{:id consumer-nexus :flag :nexus :label "nexus"} + {:id consumer-admin-console :flag :admin-console :label "admin-console"}]) -(def ^:private xf:enabled-archivers - "Static transducer: keep the archivers whose send flag is on. +(def ^:private xf:enabled-consumers + "Static transducer: keep the consumers whose send flag is on. Allocated once so hot paths (every audited RPC) do not rebuild it per call; the flag lookup itself still reads the live flags." (comp (filter #(contains? cf/flags (:flag %))) (map :id))) -(defn enabled-archivers - "Archivers allowed to work right now, from the send flags. Public: - producers queue every record for exactly these archivers." +(defn enabled-consumers + "Consumers allowed to work right now, from the send flags. Public: + producers queue every record for exactly these consumers." [] - (into [] xf:enabled-archivers archivers)) + (into [] xf:enabled-consumers consumers)) (defn queue-pendings - "Queue records for every enabled archiver, nothing when there is - nothing to queue or no archiver is on. One multi-row statement for - all ids instead of one per archiver: this runs inside every audited + "Queue records for every enabled consumer, nothing when there is + nothing to queue or no consumer is on. One multi-row statement for + all ids instead of one per consumer: this runs inside every audited RPC and the bulk frontend ingest. Runs inside the row transaction: a rollback takes both." [cfg ids] - (let [archivers (enabled-archivers)] - (when (and (seq ids) (seq archivers)) + (let [consumers (enabled-consumers)] + (when (and (seq ids) (seq consumers)) (db/insert-many! cfg :audit-log-pending - [:audit-log-id :archiver] + [:audit-log-id :consumer] (for [id ids - archiver archivers] - [id archiver]) + consumer consumers] + [id consumer]) {::sql/on-conflict-do-nothing true})))) -(def ^:private archiver-labels - (into {} (map (juxt :id :label)) archivers)) +(def ^:private consumer-labels + (into {} (map (juxt :id :label)) consumers)) -(defn- archiver-label - [archiver] - (get archiver-labels archiver)) +(defn- consumer-label + [consumer] + (get consumer-labels consumer)) (defn- decode-row [{:keys [props ip-addr context] :as row}] @@ -122,7 +122,7 @@ ;; NOTE: deliberately no SKIP LOCKED here. Batches must reach the ;; destinations in chronological order (Nexus numbers by arrival), -;; so every archiver takes the same total order and a second +;; so every consumer takes the same total order and a second ;; overlapping run waits for the first to commit, then sees whether ;; it was last. The brief blocking is accepted; skipping rows could ;; reorder archive arrival. @@ -130,14 +130,14 @@ "SELECT a.* FROM audit_log_pending p JOIN audit_log a ON a.id = p.audit_log_id - WHERE p.archiver = ?::smallint + WHERE p.consumer = ?::smallint ORDER BY a.created_at ASC, a.id ASC LIMIT 128 FOR UPDATE OF a") (def ^:private sql:delete-pendings "DELETE FROM audit_log_pending - WHERE archiver = ?::smallint + WHERE consumer = ?::smallint AND audit_log_id = ANY(?)") (def ^:private sql:delete-drained @@ -149,8 +149,8 @@ )") (defn- get-pending-rows - [{:keys [::db/conn] :as cfg} archiver] - (->> (db/exec! conn [sql:pending-chunk archiver]) + [{:keys [::db/conn] :as cfg} consumer] + (->> (db/exec! conn [sql:pending-chunk consumer]) (not-empty))) (defn- complete-batch @@ -158,11 +158,21 @@ waiting for anymore. Two statements on purpose: inside a single statement the NOT EXISTS would still see the pending just deleted and never delete a record." - [{:keys [::db/conn]} archiver rows] + [{:keys [::db/conn]} consumer rows] (let [ids (db/create-array conn "uuid" (map :id rows))] - (db/exec! conn [sql:delete-pendings archiver ids]) + (db/exec! conn [sql:delete-pendings consumer ids]) (db/exec! conn [sql:delete-drained ids]))) +(defn- generate-nexus-uri + "Joins relative path segments to the Nexus backend URI, same shape + as the admin-console helper in `app.nitrate` (there the ingest path + is `api/audit-log`; here it is `api/audit`). + Segments must not start with `/`. Returns nil when no base URI is + configured, so the caller keeps the `:task-not-configured` guard." + [& segments] + (when-let [base (cf/get :nexus-uri)] + (apply cf/join-uri base segments))) + (defn- send-to-nexus "POST events to Nexus. Returns the number of events sent; throws on transport failure or non-204 refusal, carrying the attempted count @@ -190,7 +200,7 @@ (if (= (:status resp) 204) n (throw (ex/raise :type :internal - :code :archive-chunk-refused + :code :consumer-chunk-refused :hint "nexus refused archive chunk" :resp-status (:status resp) :resp-body (truncate-body (:body resp) 500) @@ -338,8 +348,8 @@ total (count rows) skipped (- total (count events)) uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)] - (l/dbg :hint "archive events chunk ready" - :archiver "admin-console" + (l/dbg :hint "consumer events chunk ready" + :consumer "admin-console" :uri-host uri-host :total total :skipped skipped) @@ -352,15 +362,15 @@ n (count events)] (try (nitrate/call cfg :ingest-audit-log {:events enriched}) - (l/dbg :hint "archive events chunk sent" - :archiver "admin-console" + (l/dbg :hint "consumer events chunk sent" + :consumer "admin-console" :uri-host uri-host :sent n :skipped skipped) {:sent n :skipped skipped} (catch Throwable cause - (l/dbg :hint "archive events chunk failed" - :archiver "admin-console" + (l/dbg :hint "consumer events chunk failed" + :consumer "admin-console" :uri-host uri-host :events n :code :admin-console-send-failed) @@ -370,7 +380,7 @@ :cause cause :events n)))))))) -(defn- archive-nexus-chunk +(defn- consumer-nexus-chunk "Send one pending chunk to Nexus and complete it. Returns {:sent n} or nil when the queue is empty; throws on refusal. Takes a transactional cfg: the caller opens one transaction per chunk. No @@ -378,40 +388,40 @@ the caller records the success." [cfg] (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) - (when-let [rows (get-pending-rows cfg archiver-nexus)] + (when-let [rows (get-pending-rows cfg consumer-nexus)] (let [events (mapv row->event (mapv decode-row rows)) ;; send-to-nexus returns the count or throws: a refusal ;; aborts the job, the rows stay for the next tick. n (send-to-nexus cfg events)] - (l/trc :hint "archive events chunk" - :archiver "nexus" + (l/trc :hint "consumer events chunk" + :consumer "nexus" :events n) - (complete-batch cfg archiver-nexus rows) + (complete-batch cfg consumer-nexus rows) {:sent n}))) -(defn- archive-admin-console-chunk +(defn- consumer-admin-console-chunk "Send one pending chunk to the admin console and complete it. Returns {:sent :skipped} or nil when the queue is empty; throws on failure. Takes a transactional cfg, no metrics here: same contract as - archive-nexus-chunk." + consumer-nexus-chunk." [cfg] (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) - (when-let [rows (get-pending-rows cfg archiver-admin-console)] + (when-let [rows (get-pending-rows cfg consumer-admin-console)] (let [decoded (mapv decode-row rows) - _ (l/trc :hint "archive events chunk" - :archiver "admin-console" + _ (l/trc :hint "consumer events chunk" + :consumer "admin-console" :events (count decoded)) admin-console-res (send-to-admin-console cfg decoded)] ;; Filtered-out rows count as processed for the ;; admin-console queue: their pending is done, the - ;; record stays until every archiver is done. - (complete-batch cfg archiver-admin-console rows) + ;; record stays until every consumer is done. + (complete-batch cfg consumer-admin-console rows) admin-console-res))) -(defn- drain-archiver - "Drain one archiver queue chunk by chunk, one transaction per chunk. +(defn- drain-consumer + "Drain one consumer queue chunk by chunk, one transaction per chunk. Returns totals; a chunk the destination refuses (or that throws) aborts the drain with a throw, and the committed chunks so far stay sent." @@ -431,11 +441,11 @@ {:sent sent :skipped skipped :chunks chunks}))) (defn- sql:reap-pendings - [archiver] + [consumer] (str "DELETE FROM audit_log_pending" - " WHERE archiver = " archiver + " WHERE consumer = " consumer " AND audit_log_id IN (SELECT audit_log_id FROM audit_log_pending" - " WHERE archiver = " archiver + " WHERE consumer = " consumer " ORDER BY audit_log_id LIMIT 5000)")) (def ^:private sql:sweep-orphans @@ -457,35 +467,35 @@ total)))) (defn- reap-disabled - "Drop the pendings of archivers that are off: a disabled destination + "Drop the pendings of consumers that are off: a disabled destination will never drain them, and without this they would pin their records forever. This also absorbs any later deactivation, with no manual step. Dropped pendings count as discarded: their rows may still be archived - by an enabled archiver, or swept unsent when nothing waits for them." + by an enabled consumer, or swept unsent when nothing waits for them." [cfg] - (let [total (->> archivers + (let [total (->> consumers (remove #(contains? cf/flags (:flag %))) (map #(delete-batched cfg (sql:reap-pendings (:id %)))) (reduce + 0))] (when (pos? total) - (record-count (mtx/instance cfg) :audit-archive-discarded ["reaped"] total)) + (record-count (mtx/instance cfg) :audit-consumer-discarded ["reaped"] total)) total)) (defn- sweep-orphans "Delete records nobody waits for anymore: rows born while no - archiver was on, rows left pending-less by the reap, and leftovers + consumer was on, rows left pending-less by the reap, and leftovers archived by the old task. Batched and normally a no-op. Deleted rows count as discarded: on a live system they were never archived." [cfg] (let [total (delete-batched cfg sql:sweep-orphans)] (when (pos? total) - (record-count (mtx/instance cfg) :audit-archive-discarded ["swept"] total)) + (record-count (mtx/instance cfg) :audit-consumer-discarded ["swept"] total)) total)) -(declare execute-audit-log-archive-nexus) -(declare execute-audit-log-archive-admin-console) +(declare execute-audit-log-consumer-nexus) +(declare execute-audit-log-consumer-admin-console) -(def schema:audit-log-archive-nexus-params +(def schema:audit-log-consumer-nexus-params "Overrides for the nexus job repl invocation defaults. Cron submits {}; system deps (pool, shared-keys, http) live on the job-def cfg closed over by the handler, not in job params. @@ -494,7 +504,7 @@ [:map {:closed true} [:uri {:optional true} ::sm/uri]]) -(def schema:audit-log-archive-admin-console-params +(def schema:audit-log-consumer-admin-console-params "Params of the admin-console job: none. Cron submits {}; system deps (pool, admin-console client) live on the job-def cfg closed over by the handler, not in job params. Closed on purpose, same as the nexus @@ -511,16 +521,16 @@ [_ cfg] ;; Effective destination, once at boot: per-run logs carry counts, ;; the runner says the job started, this says where it sends. - (l/dbg :hint "audit log archive job initialized" - :archiver "nexus" - :uri-host (some-> (cf/get :audit-log-archive-uri) u/uri u/get-domain)) - {::jobs/name :audit-log-archive-nexus - ::jobs/schema schema:audit-log-archive-nexus-params + (l/dbg :hint "audit log consumer job initialized" + :consumer "nexus" + :uri-host (some-> (cf/get :nexus-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-consumer-nexus + ::jobs/schema schema:audit-log-consumer-nexus-params ::jobs/handler (fn [_context params] - (execute-audit-log-archive-nexus cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-archive-nexus-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-archive-nexus-params)}) + (execute-audit-log-consumer-nexus cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-consumer-nexus-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-consumer-nexus-params)}) (defmethod ig/assert-key ::admin-console-job-def [_ cfg] @@ -534,28 +544,28 @@ ;; Same as the nexus job-def: destination host once at boot. The ;; ingest uri is resolved by nitrate/call at send time (it raises ;; when missing); here it is display-only. - (l/dbg :hint "audit log archive job initialized" - :archiver "admin-console" + (l/dbg :hint "audit log consumer job initialized" + :consumer "admin-console" :uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)) - {::jobs/name :audit-log-archive-admin-console - ::jobs/schema schema:audit-log-archive-admin-console-params + {::jobs/name :audit-log-consumer-admin-console + ::jobs/schema schema:audit-log-consumer-admin-console-params ::jobs/handler (fn [_context params] - (execute-audit-log-archive-admin-console cfg params)) - ::jobs/decoder (sm/decoder schema:audit-log-archive-admin-console-params sm/json-transformer) - ::jobs/validator (sm/validator schema:audit-log-archive-admin-console-params)}) + (execute-audit-log-consumer-admin-console cfg params)) + ::jobs/decoder (sm/decoder schema:audit-log-consumer-admin-console-params sm/json-transformer) + ::jobs/validator (sm/validator schema:audit-log-consumer-admin-console-params)}) -(defn- run-single-archiver - "Drain one archiver queue, then housekeeping (reap disabled +(defn- run-single-consumer + "Drain one consumer queue, then housekeeping (reap disabled pendings, sweep orphans) and the per-run summary. Housekeeping lives in both jobs: only the enabled job runs, so it must reap the queues - of the archivers that are off; both halves are idempotent deletes. + of the consumers that are off; both halves are idempotent deletes. A refused chunk (or a throw) aborts the job: the committed chunks stay sent, the rest waits for the next tick, and the cron retries. Every effect is rows and logs (drain metrics land here, outside every chunk transaction)." - [cfg archiver send-chunk tpoint] - (let [label (archiver-label archiver) + [cfg consumer send-chunk tpoint] + (let [label (consumer-label consumer) metrics (mtx/instance cfg) reaped (reap-disabled cfg) ;; All drain metrics land here, outside every chunk @@ -567,17 +577,17 @@ ;; uncounted. The throw keeps rolling: a failed chunk aborts ;; the job, the cron retries. drained (try - (drain-archiver cfg send-chunk) + (drain-consumer cfg send-chunk) (catch Throwable cause (let [n (:events (ex-data cause))] (when (and (integer? n) (pos? n)) - (record-count metrics :audit-archive-events [label "failed"] n))) + (record-count metrics :audit-consumer-events [label "failed"] n))) (throw cause))) _ (let [{:keys [sent skipped]} drained] (when (pos? sent) - (record-count metrics :audit-archive-events [label "archived"] sent)) + (record-count metrics :audit-consumer-events [label "archived"] sent)) (when (pos? skipped) - (record-count metrics :audit-archive-events [label "skipped"] skipped))) + (record-count metrics :audit-consumer-events [label "skipped"] skipped))) swept (sweep-orphans cfg) sent (:sent drained) skipped (:skipped drained) @@ -590,9 +600,9 @@ (zero? reaped) (zero? swept)) (l/dbg :hint "no pending audit events" - :archiver label) + :consumer label) (l/dbg :hint "events archived" - :archiver label + :consumer label :elapsed elapsed :sent sent :skipped skipped @@ -600,16 +610,15 @@ :reaped reaped :swept swept)))) -(defn execute-audit-log-archive-nexus +(defn execute-audit-log-consumer-nexus "Nexus job handler: drain the nexus queue in chunks, then clean up (heartbeat per iteration: the sent chunk batches can be long)." [cfg params] ;; NOTE: this let allows overwrite default configured values from ;; the repl, when manually invoking the task. (if-not (contains? cf/flags :nexus) - (l/dbg :hint "audit log archive (nexus) skipped, flag off") - (let [uri (cf/get :audit-log-archive-uri) - uri (or uri (:uri params)) + (l/dbg :hint "audit log consumer (nexus) skipped, flag off") + (let [uri (or (:uri params) (generate-nexus-uri "api/audit")) ;; Normalize to an uri object; params may carry a plain string ;; on direct invocations (validation only applies on submit!). uri (u/uri uri) @@ -617,14 +626,14 @@ (when-not uri (ex/raise :type :internal :code :task-not-configured - :hint "archive task not configured, missing uri")) - (run-single-archiver cfg archiver-nexus archive-nexus-chunk (ct/tpoint))))) + :hint "consumer task not configured, missing uri")) + (run-single-consumer cfg consumer-nexus consumer-nexus-chunk (ct/tpoint))))) -(defn execute-audit-log-archive-admin-console +(defn execute-audit-log-consumer-admin-console "Admin-console job handler: drain the admin-console queue in chunks, then clean up (heartbeat per iteration: the sent chunk batches can be long)." [cfg _params] (if-not (contains? cf/flags :admin-console) - (l/dbg :hint "audit log archive (admin-console) skipped, flag off") - (run-single-archiver cfg archiver-admin-console archive-admin-console-chunk (ct/tpoint)))) + (l/dbg :hint "audit log consumer (admin-console) skipped, flag off") + (run-single-consumer cfg consumer-admin-console consumer-admin-console-chunk (ct/tpoint)))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 5a387f14be..7476f9bfb2 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -373,15 +373,15 @@ ::mdef/help "Total number of requests whose body the parser could not read." ::mdef/type :counter} - :audit-archive-events - {::mdef/name "penpot_audit_archive_events_total" - ::mdef/help "Audit log events sent by the archive task, by destination and outcome." + :audit-consumer-events + {::mdef/name "penpot_audit_consumer_events_total" + ::mdef/help "Audit log events drained by the consumer task, by destination and outcome." ::mdef/labels ["destination" "outcome"] ::mdef/type :counter} - :audit-archive-discarded - {::mdef/name "penpot_audit_archive_discarded_total" - ::mdef/help "Audit log entries discarded without archiving, by reason." + :audit-consumer-discarded + {::mdef/name "penpot_audit_consumer_discarded_total" + ::mdef/help "Audit log entries discarded without being archived, by reason." ::mdef/labels ["reason"] ::mdef/type :counter} @@ -593,7 +593,7 @@ :app.nitrate/client {::http.client/client (ig/ref ::http.client/client) - ;; No ::jobs/defs: nitrate never submits jobs, and archive-task's + ;; No ::jobs/defs: nitrate never submits jobs, and consumer-task's ;; job-def needs this client — that would cycle through jobs/defs. ::setup/shared-keys (ig/ref ::setup/shared-keys)} @@ -639,8 +639,8 @@ :telemetry (ig/ref :app.tasks.telemetry/job-def) :session-gc (ig/ref ::session/job-def) :file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/job-def) - :audit-log-archive-nexus (ig/ref :app.loggers.audit.archive-task/nexus-job-def) - :audit-log-archive-admin-console (ig/ref :app.loggers.audit.archive-task/admin-console-job-def)} + :audit-log-consumer-nexus (ig/ref :app.loggers.audit.consumer-task/nexus-job-def) + :audit-log-consumer-admin-console (ig/ref :app.loggers.audit.consumer-task/admin-console-job-def)} ::jobs/request-pool {::rds/client (ig/ref ::rds/client) @@ -729,13 +729,13 @@ {::db/pool (ig/ref ::db/pool) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.archive-task/nexus-job-def + :app.loggers.audit.consumer-task/nexus-job-def {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client) ::setup/shared-keys (ig/ref ::setup/shared-keys) ::mtx/metrics (ig/ref ::mtx/metrics)} - :app.loggers.audit.archive-task/admin-console-job-def + :app.loggers.audit.consumer-task/admin-console-job-def {::db/pool (ig/ref ::db/pool) :app.nitrate/client (ig/ref :app.nitrate/client) ::mtx/metrics (ig/ref ::mtx/metrics)} @@ -861,11 +861,11 @@ (when (contains? cf/flags :nexus) {:cron #penpot/cron "0 */5 * * * ?" ;; every 5m - :job :audit-log-archive-nexus}) + :job :audit-log-consumer-nexus}) (when (contains? cf/flags :admin-console) {:cron #penpot/cron "30 */5 * * * ?" ;; every 5m, offset 30s from nexus - :job :audit-log-archive-admin-console})]} + :job :audit-log-consumer-admin-console})]} ::wrk/dispatcher {::rds/client (ig/ref ::rds/client) diff --git a/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql index 4c6d8d998d..ce797dd12c 100644 --- a/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql +++ b/backend/src/app/migrations/sql/0157-add-audit-log-pending-table.sql @@ -1,8 +1,8 @@ --- Work queue for the audit log archivers: one row per (record, --- archiver) pair still pending. Inserting a record creates its --- pendings; each archiver deletes its own on completion, and whoever --- deletes the last pending also deletes the record. Archiver ids are --- fixed in code and never reused: 1 = nexus, 2 = nitrate. +-- Work queue for the audit log consumers: one row per (record, +-- consumer) pair still pending. Inserting a record creates its +-- pendings; each consumer deletes its own on completion, and whoever +-- deletes the last pending also deletes the record. Consumer ids are +-- fixed in code and never reused: 1 = nexus, 2 = admin console. CREATE TABLE audit_log_pending ( -- DEFERRABLE so the test-suite reset (which deletes table by table @@ -11,8 +11,8 @@ CREATE TABLE audit_log_pending ( -- (initially immediate) is enough: every deleter removes pendings -- before rows, so the check passes at each statement. audit_log_id uuid NOT NULL REFERENCES audit_log(id) DEFERRABLE, - archiver smallint NOT NULL CHECK (archiver BETWEEN 1 AND 20), - PRIMARY KEY (archiver, audit_log_id)); + consumer smallint NOT NULL CHECK (consumer BETWEEN 1 AND 20), + PRIMARY KEY (consumer, audit_log_id)); -- Lets the final delete ask "is anything still pending for this -- record?" without scanning the whole queue. @@ -31,8 +31,8 @@ DROP INDEX IF EXISTS audit_log__archived_at__idx; DROP INDEX IF EXISTS audit_log_id_archived_at_idx; ALTER TABLE audit_log DROP COLUMN IF EXISTS archived_at; --- Serves the archivers' chronological scan (ORDER BY created_at, id): --- one total order for every archiver, so concurrent drains cannot +-- Serves the consumers' chronological scan (ORDER BY created_at, id): +-- one total order for every consumer, so concurrent drains cannot -- deadlock on row locks. Covers the whole ORDER BY (bulk ingest -- shares request-at constantly, so ties are the norm). CREATE INDEX IF NOT EXISTS audit_log__created_at__idx diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 771b321606..4d5f26a402 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -16,7 +16,7 @@ [app.db :as db] [app.http :as-alias http] [app.loggers.audit :as audit] - [app.loggers.audit.archive-task :as archive] + [app.loggers.audit.consumer-task :as consumer] [app.loggers.database :as loggers.db] [app.loggers.mattermost :as loggers.mm] [app.metrics :as mtx] @@ -127,8 +127,8 @@ (when (seq rows) (db/insert-many! tx :audit-log event-columns rows) ;; Same transaction as the rows: every ingested record is born - ;; pending for every enabled archiver (see archive-task). - (archive/queue-pendings tx (map first rows)))) + ;; pending for every enabled consumer (see consumer-task). + (consumer/queue-pendings tx (map first rows)))) (defn- handle-events [{:keys [::db/pool] :as cfg} params] diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 8088841380..05cd056d92 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -233,8 +233,8 @@ so that the buffer stays bounded." [{:keys [::db/conn]}] ;; One transaction for both deletes: a crash between them would - ;; leave pending-less rows for the archive sweep to delete unsent. - ;; Pendings first: the queue holds a row per archiver for every + ;; leave pending-less rows for the consumer sweep to delete unsent. + ;; Pendings first: the queue holds a row per consumer for every ;; record, and the FK would reject deleting a row that still has ;; pendings. (db/tx-run! conn @@ -289,7 +289,7 @@ (defn- delete-sent-events "Delete rows by their ids after a successful send. One transaction for both deletes: a crash between them would leave pending-less - rows for the archive sweep to delete unsent." + rows for the consumer sweep to delete unsent." [conn ids] (db/tx-run! conn (fn [tx] diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj similarity index 82% rename from backend/test/backend_tests/loggers_audit_archive_task_test.clj rename to backend/test/backend_tests/loggers_audit_consumer_task_test.clj index c0e5a5a0e0..146f0da10b 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns backend-tests.loggers-audit-archive-task-test +(ns backend-tests.loggers-audit-consumer-task-test (:require [app.common.json :as json] [app.common.schema :as sm] @@ -17,7 +17,7 @@ [app.http.client :as-alias http] [app.jobs :as jobs] [app.loggers.audit :as audit] - [app.loggers.audit.archive-task :as archive] + [app.loggers.audit.consumer-task :as consumer] [app.main :as main] [app.metrics :as mtx] [app.metrics.definition :as-alias mdef] @@ -38,9 +38,9 @@ (defn- insert-pendings! "Queue the row with the real producer helper: pendings for the - enabled archivers, like append-audit-entry does." + enabled consumers, like append-audit-entry does." [row-id] - (archive/queue-pendings (:app.db/pool th/*system*) [row-id])) + (consumer/queue-pendings (:app.db/pool th/*system*) [row-id])) (defn- pending-count [] @@ -70,13 +70,13 @@ (:props (first (:events params))))) (defn- run-both! - "Run both archive jobs, Nexus first (the historical single-run + "Run both consumer jobs, Nexus first (the historical single-run order). The admin-console job takes no params (closed empty schema)." [params] - (th/run-task! :audit-log-archive-nexus params) - (th/run-task! :audit-log-archive-admin-console {})) + (th/run-task! :audit-log-consumer-nexus params) + (th/run-task! :audit-log-consumer-admin-console {})) -(t/deftest archive-jobs-drain-their-own-queues +(t/deftest consumer-jobs-drain-their-own-queues ;; Two separate jobs, one queue each: the nexus job sends without ;; touching the admin console, and only the admin-console job ;; deletes the last pending and the row. Create profile before @@ -88,14 +88,14 @@ http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-file") - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) (t/is (= 1 (:call-count @http-mock))) ;; Nexus drained its own queue; both rows wait on the admin console. (t/is (= 2 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 2 (pending-count))) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (= 1 (:call-count @admin-console-mock))) (let [[_ method params] (:call-args @admin-console-mock)] (t/is (= :ingest-audit-log method)) @@ -106,7 +106,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-filters-admin-console-event-names +(t/deftest consumer-events-filters-admin-console-event-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -123,7 +123,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-skips-admin-console-when-no-matching-names +(t/deftest consumer-events-skips-admin-console-when-no-matching-names (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -135,31 +135,31 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-admin-console-only-marks-without-uri +(t/deftest consumer-events-admin-console-only-marks-without-uri (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (t/is (false? (:called? @http-mock))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-admin-console-only-marks-non-allowlisted +(t/deftest consumer-events-admin-console-only-marks-non-allowlisted (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (false? (:called? @admin-console-mock))) (t/is (false? (:called? @http-mock))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-admin-console-error-still-drains-nexus +(t/deftest consumer-events-admin-console-error-still-drains-nexus ;; Mirror of the nexus-throw test: an admin-console failure aborts ;; only the admin-console job; Nexus already drained its own queue ;; in its job. The row stays on the admin-console pending. @@ -169,9 +169,9 @@ :throw (ex-info "nitrate down" {})} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive-admin-console {}))) + (th/run-task! :audit-log-consumer-admin-console {}))) ;; Nexus already drained its queue; the admin-console failure no ;; longer blocks it, but the row stays on the admin-console pending. (t/is (:called? @admin-console-mock)) @@ -180,44 +180,44 @@ (t/is (= 1 (count rows)))) (t/is (= 1 (pending-count))))))) -(t/deftest archive-events-uses-nexus-without-admin-console +(t/deftest consumer-events-uses-nexus-without-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} admin-console-mock {:target 'app.nitrate/call :return nil}] (insert-audit-row! (:id prof) "create-project") - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (false? (:called? @admin-console-mock))) (t/is (:called? @http-mock)) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-skips-mark-when-nexus-fails +(t/deftest consumer-events-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}))) (t/is (:called? @http-mock)) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) -(t/deftest archive-events-both-flags-skips-mark-when-nexus-fails +(t/deftest consumer-events-both-flags-skips-mark-when-nexus-fails (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 500}}] (insert-audit-row! (:id prof) "create-project") (t/is (thrown? clojure.lang.ExceptionInfo - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}))) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}))) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 1 (count rows)))))))) -(t/deftest archive-events-encodes-db-rows-for-admin-console +(t/deftest consumer-events-encodes-db-rows-for-admin-console (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console} cf/config (assoc cf/config @@ -229,16 +229,16 @@ client (ig/init-key :app.nitrate/client {::http/client (Object.) ::setup/shared-keys shared-keys}) - ;; Each archive job is a job-def: the handler closes over the + ;; Each consumer job is a job-def: the handler closes over the ;; cfg it was given at init and takes a nil context, because ;; an in-process invocation has no job row to describe. - nexus-job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + nexus-job-def (ig/init-key :app.loggers.audit.consumer-task/nexus-job-def {::db/pool (:app.db/pool th/*system*) ::setup/shared-keys shared-keys ::http/client (:app.http.client/client th/*system*) ::mtx/metrics (th/metrics-stub) :app.nitrate/client client}) - admin-console-job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + admin-console-job-def (ig/init-key :app.loggers.audit.consumer-task/admin-console-job-def {::db/pool (:app.db/pool th/*system*) ::mtx/metrics (th/metrics-stub) :app.nitrate/client client})] @@ -278,16 +278,32 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))))) -(t/deftest archive-nexus-job-requires-uri-when-nexus-flag-set +(t/deftest consumer-nexus-default-uri-appends-api-audit + ;; Without an explicit :uri param, the job posts to the Nexus base + ;; URI plus the ingest path, mirroring generate-nitrate-uri. + (let [prof (th/create-profile* 1 {:is-active true}) + uris (atom [])] + (with-redefs [cf/flags #{:nexus} + cf/config (assoc cf/config :nexus-uri (u/uri "http://nexus.example"))] + (with-mocks [http-mock {:target 'app.http.client/req + :return (fn [_ params & _] + (swap! uris conj (str (:uri params))) + {:status 204})}] + (insert-audit-row! (:id prof) "create-project") + (th/run-task! :audit-log-consumer-nexus {}) + (t/is (= ["http://nexus.example/api/audit"] @uris)) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))) + +(t/deftest consumer-nexus-job-requires-uri-when-nexus-flag-set (with-redefs [cf/flags #{:nexus} - cf/config (dissoc cf/config :audit-log-archive-uri)] + cf/config (dissoc cf/config :nexus-uri)] (let [data (ex-data (try - (th/run-task! :audit-log-archive-nexus {}) + (th/run-task! :audit-log-consumer-nexus {}) (catch Throwable cause cause)))] (t/is (= :task-not-configured (:code data)))))) -(t/deftest archive-events-enriches-team-name-and-emails +(t/deftest consumer-events-enriches-team-name-and-emails (let [actor (th/create-profile* 1 {:is-active true}) member (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] @@ -300,7 +316,7 @@ :member-id (:id member) ;; Producers store this as a string UUID. :user-who-send-invitation (str (:id actor))}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) @@ -311,7 +327,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-enriches-soft-deleted-team-and-profile +(t/deftest consumer-events-enriches-soft-deleted-team-and-profile (let [actor (th/create-profile* 1 {:is-active true}) other (th/create-profile* 2 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] @@ -322,14 +338,14 @@ (insert-audit-row! (:id actor) "create-project" {:team-id (:id team) :profile-id (:id other)}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (let [props (admin-console-event-props admin-console-mock)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email other) (:profile-email props)))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-does-not-overwrite-existing-enrich-props +(t/deftest consumer-events-does-not-overwrite-existing-enrich-props (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] @@ -345,7 +361,7 @@ :member-email "already-member@example.com" :user-who-send-invitation (str (:id actor)) :user-who-send-invitation-email "already-inviter@example.com"}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (let [props (admin-console-event-props admin-console-mock)] (t/is (= "already-set-name" (:team-name props))) (t/is (= "already-set@example.com" (:profile-email props))) @@ -354,7 +370,7 @@ (t/is (= "already-inviter@example.com" (:user-who-send-invitation-email props)))))))) -(t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids +(t/deftest consumer-events-enrich-skips-missing-and-non-uuid-ids (let [actor (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] @@ -363,7 +379,7 @@ :profile-id (uuid/next) :user-id "not-a-uuid" :member-id 42}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) (let [props (admin-console-event-props admin-console-mock)] (t/is (nil? (:team-name props))) @@ -375,7 +391,7 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-events-forwards-export-job-not-legacy-export +(t/deftest consumer-events-forwards-export-job-not-legacy-export (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] (with-redefs [cf/flags #{:admin-console}] @@ -387,7 +403,7 @@ :export-type "detach-libraries"}) (insert-audit-row! (:id actor) "export-binfile" {:file-id (uuid/next)}) - (th/run-task! :audit-log-archive-admin-console {}) + (th/run-task! :audit-log-consumer-admin-console {}) (let [[_ _ params] (:call-args @admin-console-mock) events (:events params) props (:props (first events))] @@ -397,13 +413,13 @@ (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) -(t/deftest archive-params-accept-empty-cron-props +(t/deftest consumer-params-accept-empty-cron-props ;; Cron always submits (or props {}) — system deps live on the ;; job-def cfg, not in params. Requiring them here blocks every tick. ;; Both schemas are closed: a stale manual invocation must fail ;; validation loudly instead of skipping silently. - (let [nexus-valid? (sm/validator archive/schema:audit-log-archive-nexus-params) - admin-console-valid? (sm/validator archive/schema:audit-log-archive-admin-console-params)] + (let [nexus-valid? (sm/validator consumer/schema:audit-log-consumer-nexus-params) + admin-console-valid? (sm/validator consumer/schema:audit-log-consumer-admin-console-params)] (t/is (true? (nexus-valid? {}))) (t/is (false? (nexus-valid? {:enabled true}))) (t/is (true? (nexus-valid? {:uri (u/uri archive-uri)}))) @@ -419,20 +435,20 @@ ;; (pattern from `jobs-metrics-test`), so assertions read exact values ;; instead of the shared test-system registry. `th/run-task!` would use ;; the latter and its counts would leak across tests. -(def ^:private archive-metric-ids - [:audit-archive-events :audit-archive-discarded]) +(def ^:private consumer-metric-ids + [:audit-consumer-events :audit-consumer-discarded]) -(defn- make-archive-metrics +(defn- make-consumer-metrics [] (ig/init-key :app.metrics/metrics - {:default (select-keys main/default-metrics archive-metric-ids)})) + {:default (select-keys main/default-metrics consumer-metric-ids)})) (defn- invoke-nexus! - "Invoke the nexus archive handler in-process with an isolated + "Invoke the nexus consumer handler in-process with an isolated metrics registry. The handler takes a nil context because there is no job row to describe (same as `th/run-task!`)." [metrics params] - (let [job-def (ig/init-key :app.loggers.audit.archive-task/nexus-job-def + (let [job-def (ig/init-key :app.loggers.audit.consumer-task/nexus-job-def {::db/pool (:app.db/pool th/*system*) ::setup/shared-keys {:admin-console "ac-key" :nexus "nexus-key"} ::http/client (:app.http.client/client th/*system*) @@ -440,19 +456,19 @@ ((::jobs/handler job-def) nil params))) (defn- invoke-admin-console! - "Invoke the admin-console archive handler in-process with an + "Invoke the admin-console consumer handler in-process with an isolated metrics registry. The admin-console client is the (nil in tests) system one: tests mock `app.nitrate/call`, so it is never dereferenced." [metrics] - (let [job-def (ig/init-key :app.loggers.audit.archive-task/admin-console-job-def + (let [job-def (ig/init-key :app.loggers.audit.consumer-task/admin-console-job-def {::db/pool (:app.db/pool th/*system*) :app.nitrate/client (:app.nitrate/client th/*system*) ::mtx/metrics metrics})] ((::jobs/handler job-def) nil {}))) (defn- invoke-both! - "Invoke both archive handlers sharing one isolated metrics + "Invoke both consumer handlers sharing one isolated metrics registry, Nexus first (the historical single-run order)." [metrics params] (invoke-nexus! metrics params) @@ -465,23 +481,23 @@ child (.labels ^Counter instance (into-array String labels))] (.get ^Counter$Child child))) -(t/deftest archive-records-metrics-on-success +(t/deftest consumer-records-metrics-on-success ;; Smoke test: the run records its domain metrics into the wired ;; registry (isolated per test, pattern from `jobs-metrics-test`). ;; Logging itself is not asserted: `dbg` summaries run below the ;; test log level and failure `warn`s only repeat the outcome the ;; metrics already prove. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "unrelated-event") (invoke-both! metrics {:uri archive-uri}) - (t/is (= 2.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "archived"]))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 2.0 (counter-value metrics :audit-consumer-events ["nexus" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["admin-console" "archived"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["admin-console" "skipped"]))) (t/is (= 0 (pending-count))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) @@ -499,9 +515,9 @@ :props {} :context {} :created-at (ct/now) :tracked-at (ct/now)}) -(t/deftest archive-submit-creates-pendings-for-enabled-archivers +(t/deftest consumer-submit-creates-pendings-for-enabled-consumers ;; The producer hook: a row is born pending for the enabled - ;; archivers, in the same transaction as the row itself. Goes + ;; consumers, in the same transaction as the row itself. Goes ;; through submit* because the suite stubs submit itself (see ;; helpers/init-config). (let [prof (th/create-profile* 1 {:is-active true})] @@ -511,7 +527,7 @@ (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 2 (pending-count)))))) -(t/deftest archive-submit-single-flag-creates-one-pending +(t/deftest consumer-submit-single-flag-creates-one-pending (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:audit-log :nexus}] (audit/submit* {::db/pool (:app.db/pool th/*system*)} @@ -519,7 +535,7 @@ (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count)))))) -(t/deftest archive-submit-rollback-keeps-no-pendings +(t/deftest consumer-submit-rollback-keeps-no-pendings ;; A rollback takes the row and its queue together: a row never ;; outlives its pendings, and no phantom pending pins nothing. (let [prof (th/create-profile* 1 {:is-active true})] @@ -533,13 +549,13 @@ (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count)))))) -(t/deftest archive-keeps-row-until-last-archiver-done +(t/deftest consumer-keeps-row-until-last-consumer-done ;; Nexus refuses the chunk: the nexus job aborts (failed count ;; recorded, isolated registry) while the admin-console job still ;; drains its own queue. The row and the nexus pending stay; once ;; nexus accepts, everything is gone. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (insert-audit-row! (:id prof) "create-project") (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -548,10 +564,10 @@ (invoke-nexus! metrics {:uri archive-uri}) nil (catch clojure.lang.ExceptionInfo cause cause))] - (t/is (= :archive-chunk-refused (:code (ex-data err))))) + (t/is (= :consumer-chunk-refused (:code (ex-data err))))) (invoke-admin-console! metrics) (t/is (:called? @admin-console-mock)) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["nexus" "failed"]))) (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count)))) (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -560,9 +576,9 @@ (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))))))) -(t/deftest archive-sweeps-orphan-rows +(t/deftest consumer-sweeps-orphan-rows ;; A row with no pendings (old archived leftover, or born while no - ;; archiver was on) is deleted without any send. + ;; consumer was on) is deleted without any send. (let [prof (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} @@ -584,23 +600,23 @@ (t/is (false? (:called? @http-mock))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))) -(t/deftest archive-nexus-transport-failure-records-failed +(t/deftest consumer-nexus-transport-failure-records-failed ;; A transport exception (timeout, DNS, refused) counts the events ;; as failed and aborts the nexus job: the chunk rolls back and ;; the rows stay for the next tick. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :throw (ex-info "timeout" {})}] (insert-audit-row! (:id prof) "create-project") (t/is (thrown? clojure.lang.ExceptionInfo (invoke-nexus! metrics {:uri archive-uri}))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["nexus" "failed"]))) (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count))))))) -(t/deftest archive-sends-chunks-in-chronological-order +(t/deftest consumer-sends-chunks-in-chronological-order ;; Nexus numbers by arrival for order-sensitive consumers, so ;; multi-chunk drains must arrive oldest-first no matter how the ;; queue is scanned. @@ -617,26 +633,26 @@ (th/db-update! :audit-log {:created-at (.plusSeconds ^java.time.Instant base (long i))} {:id (:id row)}))) - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (let [received (mapcat #(:events (transit/decode %)) @bodies) times (map :created-at received)] (t/is (= 130 (count received))) (t/is (= (sort times) times)) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))))) -(t/deftest archive-reaps-disabled-archiver-pendings +(t/deftest consumer-reaps-disabled-consumer-pendings ;; R3 reap by its real path: an admin-console pending born while ;; admin-console was on is dropped once it is off; nexus still ;; archives its queue and the row is gone with nothing stuck. The ;; dropped pending counts as discarded (isolated registry). (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} admin-console-mock {:target 'app.nitrate/call :return nil}] (let [row (insert-audit-row! (:id prof) "create-project")] ;; Admin console was on at insert time: both pendings on purpose. - (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, consumer) VALUES (?::uuid, 2) ON CONFLICT DO NOTHING" (:id row)]) @@ -644,16 +660,16 @@ (invoke-nexus! metrics {:uri archive-uri}) (t/is (:called? @http-mock)) (t/is (false? (:called? @admin-console-mock))) - (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["reaped"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-discarded ["reaped"]))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count)))))))) -(t/deftest archive-sweeps-rows-born-with-no-archiver-on +(t/deftest consumer-sweeps-rows-born-with-no-consumer-on ;; Accepted R3 consequence by its real path (not a hand-made ;; orphan): ingested with no send flag on, the row is swept ;; unarchived once the task runs, with zero sends. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:audit-log}] (audit/submit* {::db/pool (:app.db/pool th/*system*)} (full-event (:id prof))) @@ -665,16 +681,16 @@ (t/is (false? (:called? @http-mock))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))) - (t/is (= 1.0 (counter-value metrics :audit-archive-discarded ["swept"]))) - (t/is (= 0.0 (counter-value metrics :audit-archive-events ["nexus" "archived"]))))))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-discarded ["swept"]))) + (t/is (= 0.0 (counter-value metrics :audit-consumer-events ["nexus" "archived"]))))))) -(t/deftest archive-nexus-throw-still-drains-admin-console +(t/deftest consumer-nexus-throw-still-drains-admin-console ;; Consumer independence, now structural: a Nexus transport throw ;; aborts only the nexus job, and the admin-console job still drains ;; its own queue. Nexus keeps its pending (so the row stays); the ;; admin console completes its own queue. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req @@ -684,11 +700,11 @@ (invoke-nexus! metrics {:uri archive-uri}))) (invoke-admin-console! metrics) (t/is (:called? @admin-console-mock)) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["nexus" "failed"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["nexus" "failed"]))) (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 1 (pending-count))))))) -(t/deftest archive-reap-then-sweeps-without-sends +(t/deftest consumer-reap-then-sweeps-without-sends ;; End to end of the reap path: a row born with only an ;; admin-console pending is reaped once :admin-console goes off, ;; then swept with zero sends on either destination. @@ -699,24 +715,24 @@ (with-redefs [cf/flags #{:nexus}] (with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}} admin-console-mock {:target 'app.nitrate/call :return nil}] - (th/run-task! :audit-log-archive-nexus {:uri archive-uri}) + (th/run-task! :audit-log-consumer-nexus {:uri archive-uri}) (t/is (false? (:called? @http-mock))) (t/is (false? (:called? @admin-console-mock))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))))))) -(t/deftest archive-fully-filtered-admin-console-chunk-counts-skipped +(t/deftest consumer-fully-filtered-admin-console-chunk-counts-skipped ;; An admin-console chunk with every event outside the allowlist ;; never calls ingest, but still counts its rows as skipped and ;; completes its queue. (let [prof (th/create-profile* 1 {:is-active true}) - metrics (make-archive-metrics)] + metrics (make-consumer-metrics)] (with-redefs [cf/flags #{:nexus :admin-console}] (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil} http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "unrelated-event") (invoke-both! metrics {:uri archive-uri}) (t/is (false? (:called? @admin-console-mock))) - (t/is (= 1.0 (counter-value metrics :audit-archive-events ["admin-console" "skipped"]))) + (t/is (= 1.0 (counter-value metrics :audit-consumer-events ["admin-console" "skipped"]))) (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) (t/is (= 0 (pending-count))))))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 6080c483cf..8ae01500ad 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -139,7 +139,7 @@ (t/is (= (:id prof) (:profile-id row))) (t/is (= "navigate" (:name row))) (t/is (= "frontend" (:source row))) - ;; The row is born pending for the enabled archiver. + ;; The row is born pending for the enabled consumer. (t/is (= 1 (count (th/db-exec! ["select * from audit_log_pending"])))))))) (t/deftest push-events-2 @@ -274,7 +274,7 @@ (let [day-now (ct/truncate (ct/now) :days)] (t/is (= day-now (:created-at row2))) (t/is (= day-now (:tracked-at row2)))) - ;; Both rows are born pending for the enabled archivers. + ;; Both rows are born pending for the enabled consumers. (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 4585de8c98..2bbdd7c371 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -11,7 +11,7 @@ [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] - [app.loggers.audit.archive-task :as at] + [app.loggers.audit.consumer-task :as at] [app.tasks.telemetry :as telemetry] [app.util.json :as json] [backend-tests.helpers :as th] @@ -1006,14 +1006,14 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- insert-pending-pair! - "Queue the row for both archivers (from the archiver constants, not + "Queue the row for both consumers (from the consumer constants, not literals): the delete paths must face pending-ful rows." [row-id] - (doseq [archiver [at/archiver-nexus at/archiver-admin-console]] - (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, archiver) + (doseq [consumer [at/consumer-nexus at/consumer-admin-console]] + (th/db-exec! ["INSERT INTO audit_log_pending (audit_log_id, consumer) VALUES (?::uuid, ?::smallint) ON CONFLICT DO NOTHING" - row-id archiver]))) + row-id consumer]))) (defn- count-pendings [] (-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log_pending"]) @@ -1021,7 +1021,7 @@ long)) (t/deftest test-delete-sent-events-removes-pendings-first - ;; Regression: telemetry rows carry pendings for both archivers, so + ;; Regression: telemetry rows carry pendings for both consumers, so ;; deleting the row must delete its pendings first, or the FK ;; rejects the delete and the batch is sent again next tick. (let [row (insert-telemetry-row! "navigate")] From 7e5e5897443b10d1af9c86cb25fd2b61176163f1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 20 Oct 2026 09:18:24 +0200 Subject: [PATCH 118/325] :recycle: Use a DS icon button for the guide defaults menu (#12391) * :recycle: Use a DS icon button for the guide defaults menu Replace the raw three dots button that opens the use default / set as default menu of a guide with the DS ghost icon button. It had no label, so it gets a new "Guide defaults" one, shown in the DS tooltip, and aria-pressed while the menu is open instead of the selected class. The add, show/hide and remove guide buttons, and the new one, sit at the right edge of the sidebar: open their tooltips above and to the left. The advanced options toggle stays a raw button: it is one segment of the guide type control and an icon button would break that shape. Add a visual test that adds a guide to a board and opens its advanced options. AI-assisted-by: claude-opus-5-5 * :recycle: Remove guide defaults button styles The three dots guide defaults button is a DS icon button now: delete the .show-more-options rule, its state variables and its svg rule. The rest of frame_grid.scss already uses DS tokens. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 30 ++++++++++ .../sidebar/options/menus/frame_grid.cljs | 18 +++--- .../sidebar/options/menus/frame_grid.scss | 59 ------------------- frontend/translations/en.po | 4 ++ frontend/translations/es.po | 4 ++ 5 files changed, 48 insertions(+), 67 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 7d21687ba7..632dea1f56 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -320,6 +320,36 @@ test.describe("Assets tab", () => { }); }); +test.describe("Guides", () => { + test("Shows the advanced options of a square guide", async ({ page }) => { + // A taller window keeps the whole Design tab visible: a scrolled + // sidebar can land on a fractional offset and change the antialiasing. + await page.setViewportSize({ width: 1280, height: 900 }); + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await workspace.boardButton.click(); + await workspace.clickWithDragViewportAt(128, 128, 200, 200); + await workspace.rightSidebar + .getByRole("button", { name: "Add guide" }) + .click(); + + // The advanced options toggle has no accessible name yet. + await workspace.rightSidebar + .locator("button[class*='frame_grid__show-options']") + .click(); + await expect(workspace.rightSidebar.getByText("Color")).toBeVisible(); + // The guide options are a small part of the sidebar: compare every + // pixel. The color rows antialias their rounded corners one color step + // differently between runs, so allow that much and nothing more. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.02, + }); + }); +}); + test.describe("Left header", () => { test("Shows the shared badge and the save status", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs index ea4857a40a..1304fd3c1d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs @@ -40,14 +40,13 @@ params. Shared by the square, column and row grid-type layouts, each of which places it at a different point in their own layout." [{:keys [show disabled on-toggle]}] - [:button {:class (stl/css-case :show-more-options true - :selected show) - :on-click on-toggle - :disabled disabled} - [:> icon* {:icon-id i/menu - :size "m" - :aria-hidden true - :class (stl/css :show-options-icon)}]]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.grid.params.guide-defaults") + :aria-pressed show + :on-click on-toggle + :disabled disabled + :tooltip-placement "top-left" + :icon i/menu}]) (mf/defc default-options-dropdown* "Dropdown menu with the reset/save-as-default options for a grid's params. @@ -230,10 +229,12 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.guides.toggle-guide") :on-click handle-toggle-visibility + :tooltip-placement "top-left" :icon (if display "shown" "hide")}] [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.guides.remove-guide") :on-click on-remove + :tooltip-placement "top-left" :icon i/remove}]]] (when (:display grid) @@ -365,6 +366,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.guides.add-guide") :on-click handle-create-grid + :tooltip-placement "top-left" :icon i/add}]]] (when (and open? (seq grids)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss index ba80378e2a..aaf09cc4ee 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss @@ -272,65 +272,6 @@ inline-size: px2rem(156); } -.show-more-options { - --show-more-options-background-color: transparent; - --show-more-options-border-color: transparent; - --show-more-options-color: var(--color-foreground-secondary); - - background: none; - cursor: pointer; - display: grid; - place-content: center; - block-size: $sz-32; - inline-size: $sz-32; - border-radius: $br-8; - background-color: var(--show-more-options-background-color); - border: $b-2 solid var(--show-more-options-border-color); - color: var(--show-more-options-color); - - &:focus-visible { - outline: none; - border: $b-1 solid var(--color-accent-primary); - - --show-more-options-background-color: var(--color-background-tertiary); - --show-more-options-color: var(--color-foreground-primary); - } - - &:hover { - --show-more-options-background-color: var(--color-background-quaternary); - --show-more-options-border-color: var(--color-background-quaternary); - --show-more-options-color: var(--color-accent-primary); - } - - &:active { - outline: none; - - --show-more-options-background-color: var(--color-background-secondary); - --show-more-options-border-color: transparent; - --show-more-options-color: var(--color-accent-primary); - } - - &[disabled], - &:disabled { - --show-more-options-color: var(--color-foreground-disabled); - - cursor: unset; - pointer-events: none; - } - - &.selected { - outline: none; - - --show-more-options-background-color: var(--color-background-quaternary); - --show-more-options-border-color: var(--color-background-quaternary); - --show-more-options-color: var(--color-accent-primary); - } -} - -.show-more-options svg { - stroke: var(--show-more-options-color); -} - .height, .gutter, .margin { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 421f69a678..41a29850d8 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7361,6 +7361,10 @@ msgstr "Columns" msgid "workspace.options.grid.params.color" msgstr "Color" +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:44 +msgid "workspace.options.grid.params.guide-defaults" +msgstr "Guide defaults" + #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 msgid "workspace.options.grid.params.gutter" msgstr "Gutter" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index b102fe8821..68a7372cb7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7448,6 +7448,10 @@ msgstr "Columnas" msgid "workspace.options.grid.params.color" msgstr "Color" +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:44 +msgid "workspace.options.grid.params.guide-defaults" +msgstr "Valores por defecto de la guía" + #: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 msgid "workspace.options.grid.params.gutter" msgstr "Espaciado" From 9904e50889b4b9a072b073f2fe06d36d295cc133 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 20 Oct 2026 09:18:49 +0200 Subject: [PATCH 119/325] :recycle: Use DS icon buttons in the component options (#12394) * :recycle: Use a DS icon button for the component pill menu Replace the raw three dots button of the component pill with the DS secondary icon button. It had no label, so it reuses the "Options" one, shown in the DS tooltip above and to the left (it sits at the right edge of the sidebar), and aria-pressed while the menu is open instead of the selected class. Its class drops the button placeholders and only keeps the pill segment shape: full height and square corners on the left. The tooltip wrapper fills the segment too. Add a visual test that selects a component copy so the pill shows. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS icon buttons for the component annotation actions The save/create, discard, edit and delete actions of a component annotation were divs with a click handler: no keyboard focus and no button role. Replace them with DS ghost icon buttons. Their title moves to the label, shown in the DS tooltip above and to the left, and save is disabled while the text is blank, which it already ignored. The class that hides them until the title is hovered, or while editing or creating, now goes on the tooltip wrapper. The green and red hover colors and the fixed size go away with the DS look. While editing or creating, the actions get a small padding so their hover background does not touch the annotation border; the title row grows from 32 to 36px there. Add a visual test that creates an annotation on a main component and shows both sets of actions. AI-assisted-by: claude-opus-5-5 * :lipstick: Open component options tooltips above and to the left The variants help, create variant, add property and remove property icon buttons sit at the right edge of the sidebar: open their tooltips above and to the left, like the other actions of the component options. AI-assisted-by: claude-opus-5-5 * :recycle: Move component options styles off the deprecated styles Inline the dropdown placeholders of the component pill menu with DS tokens and a local variable for the item colors that change on hover, and give the item label its own class instead of styling the span. Resolve the annotation title border alias to its DS token and delete the unused .component-title-actions rule. The item label uses the DS body-small typography; its line-height is 1.3 instead of 1.4, with no visible change since the item centers it. component.scss no longer imports refactor/. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 66 +++++++++++ .../sidebar/options/menus/component.cljs | 73 +++++++----- .../sidebar/options/menus/component.scss | 104 +++++++++++------- 3 files changed, 172 insertions(+), 71 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 632dea1f56..1c382e7c25 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -264,6 +264,72 @@ test.describe("Design tab", () => { }); }); +test.describe("Component options", () => { + test("Shows the component pill of a copy", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + await workspace.layers.getByText("A", { exact: true }).click(); + + await expect( + workspace.rightSidebar.getByTestId("component-pill-button"), + ).toBeVisible(); + // The component pill is a small part of the sidebar: compare every + // pixel. + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + + test("Shows the annotation actions of a main component", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + await workspace.layers.getByText("B", { exact: true }).click(); + await workspace.rightSidebar + .getByRole("button", { name: "Options" }) + .click(); + await workspace.rightSidebar.getByText("Create annotation").click(); + + // Creating: save (disabled while the text is empty) and discard. + const annotation = workspace.rightSidebar.locator("#annotation-textarea"); + await expect(annotation).toBeFocused(); + await expect(workspace.rightSidebar).toHaveScreenshot( + "annotation-creating.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + // Saved: edit and delete show when hovering the title. + await annotation.fill("An annotation"); + await workspace.rightSidebar + .getByRole("button", { name: "Create", exact: true }) + .click(); + const title = workspace.rightSidebar.getByText("Annotation", { + exact: true, + }); + await title.hover(); + await expect(workspace.rightSidebar).toHaveScreenshot( + "annotation-saved.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + }); +}); + test.describe("Assets tab", () => { test("Shows the libraries modal correctly", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 8d46f46a3f..00660a3dfb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -198,31 +198,35 @@ (if (or ^boolean editing? ^boolean creating?) [:* - [:div {:title (if ^boolean creating? - (tr "labels.create") - (tr "labels.save")) - :on-click on-save - :class (stl/css :annotation-title-icon-action)} - [:> icon* {:icon-id i/tick - :class (stl/css-case :annotation-title-icon-ok true - :disabled invalid-text?)}]] - [:div {:class (stl/css :annotation-title-icon-action) - :title (tr "labels.discard") - :on-click on-discard} - [:> icon* {:icon-id i/close - :class (stl/css :annotation-title-icon-nok)}]]] + [:> icon-button* {:variant "ghost" + :aria-label (if ^boolean creating? + (tr "labels.create") + (tr "labels.save")) + :on-click on-save + :disabled invalid-text? + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/tick}] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.discard") + :on-click on-discard + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/close}]] [:* - [:div {:class (stl/css :annotation-title-icon-action) - :title (tr "labels.edit") - :on-click on-edit} - [:> icon* {:icon-id i/curve - :class (stl/css :annotation-title-icon-ok)}]] - [:div {:class (stl/css :annotation-title-icon-action) - :title (tr "labels.delete") - :on-click on-delete-annotation} - [:> icon* {:icon-id i/delete - :class (stl/css :annotation-title-icon-nok)}]]]))]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.edit") + :on-click on-edit + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/curve}] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.delete") + :on-click on-delete-annotation + :tooltip-class (stl/css :annotation-title-icon-action) + :tooltip-placement "top-left" + :icon i/delete}]]))]] [:div {:class (stl/css-case :annotation-body-hidden (not expanded?))} [:div {:class (stl/css :annotation-body)} @@ -896,10 +900,14 @@ (when menu-entries? [:div {:class (stl/css :pill-actions)} - [:button {:class (stl/css-case :pill-actions-btn true - :selected menu-open?) - :on-click on-menu-click} - [:> icon* {:icon-id i/menu}]] + [:> icon-button* {:variant "secondary" + :class (stl/css :pill-actions-btn) + :tooltip-class (stl/css :pill-actions-tooltip) + :aria-label (tr "labels.options") + :aria-pressed menu-open? + :tooltip-placement "top-left" + :on-click on-menu-click + :icon i/menu}] [:& dropdown {:show menu-open? :on-close on-menu-close} @@ -910,7 +918,7 @@ [:li {:key title :class (stl/css :pill-actions-dropdown-item) :on-click (partial do-action action)} - [:span title]]))]]])])) + [:span {:class (stl/css :pill-actions-dropdown-item-label)} title]]))]]])])) (mf/defc component-menu* [{:keys [shapes is-swap-opened]}] @@ -1039,12 +1047,14 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.component.variants-help-modal.title") :on-click on-click-variant-title-help + :tooltip-placement "top-left" :icon i/help}]) (when main-instance? [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant") :on-click (if is-variant? create-variant transform-into-variant) + :tooltip-placement "top-left" :icon i/variant}])])] (when open? @@ -1067,6 +1077,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant-property") :on-click add-new-property + :tooltip-placement "top-left" :icon i/add}])] (when is-swap-opened @@ -1151,7 +1162,8 @@ (tr "workspace.shape.menu.remove-variant-property.last-property") (tr "workspace.shape.menu.remove-variant-property")) :on-click on-remove - :disabled is-remove-disabled}]]])) + :disabled is-remove-disabled + :tooltip-placement "top-left"}]]])) (mf/defc component-variant-main* [{:keys [shapes]}] @@ -1283,10 +1295,12 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.component.variants-help-modal.title") :on-click on-click-variant-title-help + :tooltip-placement "top-left" :icon i/help}] [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant") :on-click (partial create-variant "workspace:button-design-tab-component") + :tooltip-placement "top-left" :icon i/variant}]]] (when open? @@ -1301,6 +1315,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.shape.menu.add-variant-property") :on-click (partial add-new-property "workspace:button-design-tab-component") + :tooltip-placement "top-left" :icon i/add}]] (when-not multi? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index ca8bbd7aab..acbe025003 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -10,7 +10,6 @@ @use "ds/spacing.scss" as *; @use "ds/typography.scss" as t; @use "../../../sidebar/common/sidebar.scss" as sidebar; -@use "refactor/common-refactor.scss" as deprecated; .annotation { @include t.use-typography("body-small"); @@ -23,7 +22,12 @@ border: $b-1 solid var(--color-accent-primary); .annotation-title { - border-bottom: $b-1 solid var(--entry-border-color-disabled); + border-bottom: $b-1 solid var(--color-background-quaternary); + } + + // Keep the hover background of the actions off the border. + .annotation-title-actions { + padding: var(--sp-xxs); } .annotation-title-icon-action { @@ -46,6 +50,10 @@ display: flex; } + .annotation-title-actions { + padding: var(--sp-xxs); + } + .annotation-title-name { margin-inline-start: var(--sp-m); } @@ -59,7 +67,7 @@ .annotation-title { display: flex; align-items: center; - block-size: $sz-32; + min-block-size: $sz-32; &.expanded { border-bottom: $b-1 solid var(--color-background-quaternary); @@ -90,29 +98,10 @@ line-height: 2.5; } -.annotation-title-icon-action { - cursor: pointer; +// Tooltip wrapper of each action: hidden until the title is hovered or the +// annotation is being edited or created. +.annotation-title-actions .annotation-title-icon-action { display: none; - justify-content: center; - align-items: center; - inline-size: $sz-28; - block-size: $sz-32; - border-radius: $br-8; - margin: 0; - padding: 0; -} - -.annotation-title-icon-ok:hover { - color: var(--color-accent-success); - - &.disabled { - cursor: default; - color: var(--color-foreground-secondary); - } -} - -.annotation-title-icon-nok:hover { - color: var(--color-accent-error); } .annotation-body-hidden { @@ -489,11 +478,6 @@ color: var(--color-foreground-secondary); } -.component-title-actions { - display: flex; - gap: var(--sp-xs); -} - .component-content { grid-column: span 8; display: flex; @@ -588,25 +572,35 @@ position: relative; } -.pill-actions-btn { - @extend %button-secondary; +// The tooltip wrapper is the flex item: let it fill the pill segment. +.pill-actions-tooltip { + block-size: 100%; + inline-size: 100%; +} - cursor: unset; +// Right segment of the pill: full height and square corners on the left. +.pill-actions-btn { block-size: 100%; inline-size: 100%; border-radius: 0 $br-8 $br-8 0; - - &.selected { - @extend %button-icon-selected; - } } .pill-actions-dropdown { - @extend %dropdown-wrapper; - - inline-size: $sz-252; + position: absolute; + inset-block-start: $sz-32; inset-inline-end: 0; - inset-inline-start: unset; + inline-size: $sz-252; + max-block-size: var(--menu-max-height, px2rem(300)); + padding: var(--sp-xxs); + margin: 0; + margin-block-start: px2rem(1); + border: $b-2 solid var(--color-background-quaternary); + border-radius: $br-8; + box-shadow: 0 0 px2rem(12) 0 var(--color-shadow-dark); + z-index: var(--z-index-dropdown); + overflow: hidden auto; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); &.extended { inset-block-start: $sz-48; @@ -614,7 +608,33 @@ } .pill-actions-dropdown-item { - @extend %dropdown-element-base; + @include t.use-typography("body-small"); + + --pill-actions-dropdown-item-foreground-color: var(--color-foreground-secondary); + --pill-actions-dropdown-item-background-color: transparent; + + display: flex; + align-items: center; + gap: var(--sp-s); + block-size: $sz-32; + padding: 0 var(--sp-s); + border-radius: $br-6; + cursor: pointer; + color: var(--pill-actions-dropdown-item-foreground-color); + background-color: var(--pill-actions-dropdown-item-background-color); + + &:hover { + --pill-actions-dropdown-item-foreground-color: var(--color-foreground-primary); + --pill-actions-dropdown-item-background-color: var(--color-background-quaternary); + } +} + +.pill-actions-dropdown-item-label { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .variant-property-list { From 47d7ad2de17e9817138717c9887fa3b2936d5156 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 20 Oct 2026 09:19:17 +0200 Subject: [PATCH 120/325] :recycle: Explain layout_container's deprecated import and format visual spec (#12449) * :recycle: Explain why layout_container.scss keeps the deprecated import The TODO at the top said to remove the refactor/ import once the buttons were replaced, and sat above the DS imports instead of that one. The buttons are now DS buttons (#12428, #12431), but the import is still needed by the numeric inputs (%input-element, %disabled-input) and by the two raw track header buttons, which keep the secondary and tertiary placeholders. Replace the TODO with a comment on the import that says so. AI-assisted-by: claude-opus-5-5 * :paperclip: Format the workspace visual spec with Prettier Three expectations added in #12423, #12428 and #12431 broke a call chain in a way Prettier rewrites. check-fmt:js did not catch it: its playwright/**/*.js glob is expanded by the shell without globstar, so it does not reach visual-specs/. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/visual-specs/workspace.spec.js | 15 +++++++++------ .../sidebar/options/menus/layout_container.scss | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 1c382e7c25..f18514cc9d 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -71,8 +71,9 @@ test.describe("Grid layout", () => { await workspace.clickLeafLayer("Board"); const section = page.getByTestId("inspect-layout"); - await expect(section.getByRole("button", { name: "Edit grid" })) - .toBeVisible(); + await expect( + section.getByRole("button", { name: "Edit grid" }), + ).toBeVisible(); // The buttons are a small part of the section: compare every pixel. await expect(section).toHaveScreenshot("grid-layout-options.png", { maxDiffPixelRatio: 0, @@ -187,8 +188,9 @@ test.describe("Design tab", () => { await padding.click(); await padding.blur(); await page.mouse.move(0, 0); - await expect(section.getByRole("textbox", { name: /top padding/i })) - .toBeVisible(); + await expect( + section.getByRole("textbox", { name: /top padding/i }), + ).toBeVisible(); await expect(section).toHaveScreenshot("flex-layout-pressed.png", { maxDiffPixelRatio: 0, threshold: 0, @@ -255,8 +257,9 @@ test.describe("Design tab", () => { await marginMode.click(); await marginMode.blur(); await page.mouse.move(0, 0); - await expect(section.getByRole("textbox", { name: "Top margin" })) - .toBeVisible(); + await expect( + section.getByRole("textbox", { name: "Top margin" }), + ).toBeVisible(); await expect(section).toHaveScreenshot("margin-multiple.png", { maxDiffPixelRatio: 0, threshold: 0, diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 34f3d81b43..c92abb2dc9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -4,13 +4,15 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -// TODO: When button replace remove this @use @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/typography.scss" as t; @use "ds/spacing.scss" as *; @use "ds/mixins.scss" as *; + +// Only for %input-element and %disabled-input (numeric inputs) and the +// secondary and tertiary placeholders of the raw track header buttons. @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; From ecfa350470bf92ac2a8ac5e5696c58d155264092 Mon Sep 17 00:00:00 2001 From: Shreyash Agare Date: Tue, 20 Oct 2026 14:57:31 +0530 Subject: [PATCH 121/325] :sparkles: Auto-scroll sortable lists while dragging near their edges (#11595) * :sparkles: Auto-scroll sortable lists while dragging near their edges Native drag and drop swallows wheel events, so a long list could only be scrolled mid-drag by hitting the browser's narrow auto-scroll strip at the very edge of the container. use-sortable now scrolls the item's nearest scrollable ancestor on every animation frame while the pointer sits within 56px of its top or bottom edge, ramping the speed up to 18px per frame as the pointer gets closer. Every list built on the hook (layers, sitemap, option rows, gradients, token sets) gets it without any change on their side. * :sparkles: Cap auto-scroll zones and test the scroll loop Each edge zone now takes at most a quarter of the container, so the middle half of a short list, like the pages panel, never scrolls. The scroll container is found once when the drag starts. The speed is scaled by the time between frames, so 120 Hz screens no longer scroll twice as fast. Starting a drag also stops any loop left over from a drag whose end event was lost. auto-scroll-speed is now private. New tests cover the running loop: only one loop starts, it stops on drag end, when the speed goes back to zero and after 500ms without dragover, and the frame time scaling. AI-assisted-by: claude-opus-5-5 --------- Co-authored-by: Shreyash Agare Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui/hooks.cljs | 122 +++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/sortable_auto_scroll_test.cljs | 178 ++++++++++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 3225932153..5bc113bab9 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -87,6 +87,122 @@ ;; Do not remove commented out lines, they are useful to debug events when ;; things go weird. +;; Native drag and drop swallows wheel events while a drag is in progress, so +;; scrolling a long list mid-drag is done here: when the pointer gets near the +;; top or bottom edge of the scrollable container, it is scrolled on every +;; animation frame, faster the closer the pointer is to the edge. + +(def ^:private auto-scroll-zone-size 56) +(def ^:private auto-scroll-max-speed 18) +(def ^:private auto-scroll-stale-ms 500) + +;; Speeds are given in pixels per frame at 60 Hz and scaled by the real time +;; between frames, so the scroll goes as fast on a 120 Hz screen. +(def ^:private auto-scroll-frame-ms (/ 1000 60)) +(def ^:private auto-scroll-max-frame-ms 100) + +;; State of the current drag: the scroll container, found once when the drag +;; starts, the scroll speed, the pending animation frame and when the pointer +;; was last seen over the list. +(defonce ^:private auto-scroll-state (atom nil)) + +(defn- scrollable-ancestor + [node] + (loop [node (some-> ^js node .-parentElement)] + (when (some? node) + (let [overflow (-> (dom/get-computed-styles node) + (dom/get-property-value "overflow-y"))] + (if (and (or (= overflow "auto") (= overflow "scroll")) + (> (.-scrollHeight ^js node) (.-clientHeight ^js node))) + node + (recur (.-parentElement ^js node))))))) + +(defn- auto-scroll-speed + "Pixels to scroll on each frame given the pointer position, negative + upwards. Zero when the pointer is outside the edge zones. The zones take + at most a quarter of the container each, so the middle half of a short + list never scrolls." + [{:keys [top bottom]} pointer-y] + (let [zone (mth/min auto-scroll-zone-size (/ (- bottom top) 4)) + top-distance (- pointer-y top) + bottom-distance (- bottom pointer-y) + ramp (fn [distance] + (-> (- zone distance) + (/ zone) + (* auto-scroll-max-speed) + (mth/ceil)))] + (cond + (or (neg? top-distance) (neg? bottom-distance)) 0 + (< top-distance zone) (- (ramp top-distance)) + (< bottom-distance zone) (ramp bottom-distance) + :else 0))) + +(defn- stop-auto-scroll! + "Stops the scroll loop, keeping the scroll container for the rest of the + drag." + [] + (some-> (:frame @auto-scroll-state) ts/cancel-af!) + (swap! auto-scroll-state dissoc :frame :last-frame-at :offset)) + +(defn- end-auto-scroll! + [] + (stop-auto-scroll!) + (reset! auto-scroll-state nil)) + +(defn- start-auto-scroll! + "Remembers the scroll container of the dragged `node`. A loop left over + from a previous drag whose end was lost is stopped first." + [node] + (stop-auto-scroll!) + (reset! auto-scroll-state {:element (scrollable-ancestor node)})) + +(defn- auto-scroll-tick + [timestamp] + (let [{:keys [element speed updated-at last-frame-at offset]} @auto-scroll-state] + ;; The stale check stops the loop if the drag ended without us being + ;; notified, for example when a dragend event is lost. + (if (or (nil? element) + (> (- timestamp updated-at) auto-scroll-stale-ms)) + (end-auto-scroll!) + (let [;; Capped so a late frame does not make the list jump. + elapsed (if (some? last-frame-at) + (mth/min (- timestamp last-frame-at) auto-scroll-max-frame-ms) + auto-scroll-frame-ms) + ;; Keep the fraction of a pixel for the next frame, otherwise + ;; slow speeds would be lost on fast screens. + offset (+ (or offset 0) (* speed (/ elapsed auto-scroll-frame-ms))) + step (js/Math.trunc offset)] + (when-not (zero? step) + (dom/scroll-by! element 0 step)) + (swap! auto-scroll-state assoc + :offset (- offset step) + :last-frame-at timestamp + :frame (ts/raf auto-scroll-tick)))))) + +(defn- update-auto-scroll! + "Starts, updates or stops the auto scroll of the container of `node` + depending on how close the pointer is to its edges." + [node pointer-y] + (let [element (:element @auto-scroll-state) + ;; The drag may have started outside this list, or the loop ended + ;; on the stale check: find the container of `node` again. + element (if (dom/child? node element) + element + (scrollable-ancestor node)) + speed (if (some? element) + (auto-scroll-speed (dom/get-bounding-rect element) pointer-y) + 0)] + (swap! auto-scroll-state assoc + :element element + :speed speed + :updated-at (js/performance.now)) + (cond + (zero? speed) + (stop-auto-scroll!) + + (nil? (:frame @auto-scroll-state)) + (swap! auto-scroll-state assoc :frame (ts/raf auto-scroll-tick))))) + (defn use-sortable [& {:keys [data-type data on-drop on-drag on-hold disabled detect-center? draggable?] :or {draggable? true} @@ -123,6 +239,7 @@ (dnd/set-data! event data-type data) (dnd/set-drag-image! event (dnd/invisible-image)) (dnd/set-allowed-effect! event "move") + (start-auto-scroll! (mf/ref-val ref)) (when (fn? on-drag) (on-drag data))))) @@ -148,7 +265,8 @@ (subscribe-to-drag-end) ;; (dnd/trace event data "drag-over") (let [side (dnd/drop-side event detect-center?)] - (swap! state assoc :over side))))) + (swap! state assoc :over side))) + (update-auto-scroll! (mf/ref-val ref) (.-clientY event)))) on-drag-leave (fn [event] @@ -162,6 +280,7 @@ ;; (dnd/trace event data "drop") (let [side (dnd/drop-side event detect-center?) drop-data (dnd/get-data event data-type)] + (end-auto-scroll!) (cleanup) (rx/push! global-drag-end nil) (when (fn? on-drop) @@ -171,6 +290,7 @@ (fn [event] (dom/stop-propagation event) ;; (dnd/trace event data "drag-end") + (end-auto-scroll!) (rx/push! global-drag-end nil) (cleanup)) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e5d2a3c11a..78743cea8b 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -136,6 +136,7 @@ [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] + [frontend-tests.ui.sortable-auto-scroll-test] [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.text-attrs-multiple-test] [frontend-tests.ui.workspace-libraries-test] @@ -296,6 +297,7 @@ 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test + 'frontend-tests.ui.sortable-auto-scroll-test 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.text-attrs-multiple-test 'frontend-tests.ui.workspace-libraries-test diff --git a/frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs b/frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs new file mode 100644 index 0000000000..fb4d8348ac --- /dev/null +++ b/frontend/test/frontend_tests/ui/sortable_auto_scroll_test.cljs @@ -0,0 +1,178 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.sortable-auto-scroll-test + (:require + [app.common.math :as mth] + [app.main.ui.hooks :as hooks] + [app.util.timers :as ts] + [cljs.test :as t :include-macros true])) + +(def ^:private auto-scroll-speed @#'hooks/auto-scroll-speed) +(def ^:private auto-scroll-state @#'hooks/auto-scroll-state) +(def ^:private start-auto-scroll! @#'hooks/start-auto-scroll!) +(def ^:private update-auto-scroll! @#'hooks/update-auto-scroll!) +(def ^:private end-auto-scroll! @#'hooks/end-auto-scroll!) + +(t/use-fixtures :each {:before #(reset! auto-scroll-state nil) + :after #(reset! auto-scroll-state nil)}) + +;; A 400px tall list, with the 56px edge zones at [0, 56] and [344, 400]. +(def ^:private rect {:top 0 :bottom 400}) + +(t/deftest auto-scroll-speed-test + (t/testing "does not scroll when the pointer is away from the edges" + (t/is (= 0 (auto-scroll-speed rect 200))) + (t/is (= 0 (auto-scroll-speed rect 56))) + (t/is (= 0 (auto-scroll-speed rect 344)))) + + (t/testing "does not scroll when the pointer is outside the container" + (t/is (= 0 (auto-scroll-speed rect -10))) + (t/is (= 0 (auto-scroll-speed rect 410)))) + + (t/testing "scrolls upwards near the top edge and downwards near the bottom" + (t/is (neg? (auto-scroll-speed rect 10))) + (t/is (pos? (auto-scroll-speed rect 390)))) + + (t/testing "scrolls faster the closer the pointer gets to the edge" + (t/is (< (auto-scroll-speed rect 5) (auto-scroll-speed rect 50) 0)) + (t/is (< 0 (auto-scroll-speed rect 350) (auto-scroll-speed rect 395))))) + +(t/deftest auto-scroll-speed-short-list-test + ;; In a 100px list each zone is capped at 25px, a quarter of the height. + (let [rect {:top 0 :bottom 100}] + (t/testing "the middle half of the list never scrolls" + (t/is (= 0 (auto-scroll-speed rect 25))) + (t/is (= 0 (auto-scroll-speed rect 50))) + (t/is (= 0 (auto-scroll-speed rect 75)))) + + (t/testing "the capped zones still scroll" + (t/is (neg? (auto-scroll-speed rect 10))) + (t/is (pos? (auto-scroll-speed rect 90)))))) + +(defn- scroll-container + "A fake 400px tall scroll container that records the scrolled pixels." + [scrolled] + #js {:contains (fn [_] true) + :getBoundingClientRect (fn [] #js {:top 0 :bottom 400}) + :scrollBy (fn [_ y] (swap! scrolled conj y))}) + +(defn- start-drag! + [element] + (reset! auto-scroll-state {:element element})) + +(t/deftest start-auto-scroll-without-container-test + (start-auto-scroll! #js {:parentElement nil}) + (t/is (= {:element nil} @auto-scroll-state))) + +(t/deftest start-auto-scroll-stops-previous-loop-test + ;; The previous drag ended without a dragend, so its loop is still running. + (let [cancelled (atom [])] + (with-redefs [ts/cancel-af! (fn [frame] (swap! cancelled conj frame))] + (reset! auto-scroll-state {:element #js {} :frame 9}) + (start-auto-scroll! #js {:parentElement nil}) + (t/is (= [9] @cancelled)) + (t/is (= {:element nil} @auto-scroll-state))))) + +(t/deftest update-auto-scroll-starts-one-loop-test + (let [scheduled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) (count @scheduled))] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (update-auto-scroll! #js {} 5) + (update-auto-scroll! #js {} 1) + (t/is (= 1 (count @scheduled)) "dragover events reuse the running loop") + (t/is (= 1 (:frame @auto-scroll-state))) + (t/is (= (auto-scroll-speed rect 1) (:speed @auto-scroll-state)) + "the loop uses the latest pointer position")))) + +(t/deftest update-auto-scroll-away-from-edges-does-not-start-test + (let [scheduled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 1)] + (start-drag! element) + (update-auto-scroll! #js {} 200) + (t/is (empty? @scheduled))))) + +(t/deftest auto-scroll-tick-scrolls-and-continues-test + (let [scheduled (atom []) + scrolled (atom []) + element (scroll-container scrolled)] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) (count @scheduled))] + (start-drag! element) + (update-auto-scroll! #js {} 0) + ((last @scheduled) (js/performance.now)) + (t/is (= [(auto-scroll-speed rect 0)] @scrolled)) + (t/is (= 2 (count @scheduled)) "the next frame is scheduled") + (t/is (= 2 (:frame @auto-scroll-state)))))) + +(t/deftest auto-scroll-stops-when-speed-goes-back-to-zero-test + (let [scheduled (atom []) + cancelled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 7) + ts/cancel-af! (fn [frame] (swap! cancelled conj frame))] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (update-auto-scroll! #js {} 200) + (t/is (= [7] @cancelled) "the pending frame is cancelled") + (t/is (nil? (:frame @auto-scroll-state))) + (t/is (identical? element (:element @auto-scroll-state)) + "the container is kept for the rest of the drag") + + (update-auto-scroll! #js {} 10) + (t/is (= 2 (count @scheduled)) "it starts again near the edge")))) + +(t/deftest auto-scroll-stops-on-drag-end-test + (let [cancelled (atom []) + element (scroll-container (atom []))] + (with-redefs [ts/raf (fn [_] 3) + ts/cancel-af! (fn [frame] (swap! cancelled conj frame))] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (end-auto-scroll!) + (t/is (= [3] @cancelled)) + (t/is (nil? @auto-scroll-state))))) + +(t/deftest auto-scroll-stale-guard-test + (let [scheduled (atom []) + scrolled (atom []) + element (scroll-container scrolled)] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 1) + ts/cancel-af! (fn [_])] + (start-drag! element) + (update-auto-scroll! #js {} 10) + (let [updated-at (:updated-at @auto-scroll-state)] + ;; No dragover for more than 500ms, as when a dragend is lost. + ((last @scheduled) (+ updated-at 501))) + (t/is (empty? @scrolled) "it does not scroll on a stale frame") + (t/is (= 1 (count @scheduled)) "no further frames are scheduled") + (t/is (nil? @auto-scroll-state))))) + +(defn- scroll-for-two-60hz-frames + "Runs the loop for the time of two 60 Hz frames, at `frames` frames per + 60 Hz frame, and returns the scrolled pixels. The first frame has no + previous one to measure from, so it counts as a full 60 Hz frame." + [frames] + (let [scheduled (atom []) + scrolled (atom []) + frame-ms (/ 1000 60 frames)] + (with-redefs [ts/raf (fn [f] (swap! scheduled conj f) 1)] + (reset! auto-scroll-state nil) + (start-drag! (scroll-container scrolled)) + (update-auto-scroll! #js {} 0) + (let [start (:updated-at @auto-scroll-state)] + (dotimes [i (inc frames)] + ((last @scheduled) (+ start (* i frame-ms)))))) + (reduce + @scrolled))) + +(t/deftest auto-scroll-speed-does-not-depend-on-refresh-rate-test + (let [at-60hz (scroll-for-two-60hz-frames 1) + at-120hz (scroll-for-two-60hz-frames 2)] + (t/is (neg? at-60hz)) + (t/is (<= (mth/abs (- at-120hz at-60hz)) 1) + "a 120 Hz screen scrolls the same distance in the same time"))) From a5dac030735d21da63a9c0dd54de7e5702c98248 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 20 Oct 2026 14:08:40 +0200 Subject: [PATCH 122/325] :sparkles: Add organization SSO and invitation audit events for nitrate (#12461) --- backend/src/app/auth/oidc.clj | 4 +- .../src/app/loggers/audit/consumer_task.clj | 7 ++++ backend/src/app/rpc/commands/verify_token.clj | 35 +++++++++------- backend/src/app/rpc/management/nitrate.clj | 14 ++++++- backend/test/backend_tests/auth_oidc_test.clj | 32 +++++++++++++++ .../loggers_audit_consumer_task_test.clj | 3 +- .../rpc_management_nitrate_test.clj | 28 +++++++++++++ backend/test/backend_tests/rpc_team_test.clj | 41 +++++++++++++++++-- 8 files changed, 141 insertions(+), 23 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 05121e7479..be8c9078eb 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -1037,7 +1037,9 @@ (when (and session organization-id) (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] - (session/update-session (::session/manager cfg) (assoc session :props props)))) + (session/update-session (::session/manager cfg) (assoc session :props props)) + (submit-organization-sso-auth-event + cfg request (:profile-id session) organization-id "organization-sso-token-issued"))) (submit-organization-sso-auth-event cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded") (redirect-response dest-url)) diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 6c4986eed5..9fa05c5e70 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -208,10 +208,13 @@ (def ^:private event-names-for-admin-console #{"accept-organization-invitation" + "accept-organization-invitation-from" "accept-team-invitation" "accept-team-invitation-from" "add-member-to-organization" "add-team-to-organization" + "auditlog-exported" + "auditlog-guide-downloaded" "cancel-organization-invitation" "create-export-binfile-job" "create-file" @@ -238,12 +241,16 @@ "organization-sso-auth-failed" "organization-sso-auth-started" "organization-sso-auth-succeeded" + "organization-sso-token-issued" + "organization-sso-token-revoked" "permanently-delete-team-files" "remove-organization-team" "remove-team-from-organization" "rename-organization" "rename-project" "restore-deleted-team-files" + "sso-config-deleted" + "sso-config-tested" "success-activate-organization-sso" "success-apply-organization-sso-changes" "update-organization-invitation" diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 26746382c8..4a0b84981f 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -302,21 +302,26 @@ ;; for the same name, and tied the record to the browser finishing ;; the flow. Everything the copy carried is computed above. (when organization-id-on-add - (audit/submit cfg (-> (audit/event-from-rpc-params params) - (assoc :name "accept-organization-invitation") - (assoc :props - (-> props - (assoc :organization-id organization-id-on-add) - (assoc :invited-by (:created-by invitation)) - (assoc :profile-id (:id profile)) - (assoc :profile-email (:email profile)) - (assoc :organization-member-add-source - organization-add-source) - (assoc :belongs-to-team-on-add - (boolean team-id)) - (assoc :organization-member-count-before - organization-member-count-before) - (audit/clean-props)))))) + (let [org-props (-> props + (assoc :organization-id organization-id-on-add + :invited-by (:created-by invitation) + :profile-id (:id profile) + :profile-email (:email profile) + :organization-member-add-source + organization-add-source + :belongs-to-team-on-add + (boolean team-id) + :organization-member-count-before + organization-member-count-before) + (audit/clean-props))] + (audit/submit cfg (-> (audit/event-from-rpc-params params) + (assoc :name "accept-organization-invitation" + :props org-props))) + (when-let [created-by (:created-by invitation)] + (audit/submit cfg (-> (audit/event-from-rpc-params params) + (assoc :profile-id created-by + :name "accept-organization-invitation-from" + :props org-props)))))) (cond-> (assoc claims :state :created diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index ac18a6c10d..c8c4f83e8c 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -1060,10 +1060,20 @@ RETURNING id, deleted_at;") [:updated-props ::sm/boolean] [:announce-activation ::sm/boolean]] ::rpc/auth false} - [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}] + [{:keys [::db/pool] :as cfg} + {:keys [organization-id updated-props announce-activation] :as params}] (when updated-props (rpc/invalidate-organization-sso-cache-by-organization! organization-id) - (session/clear-organization-sso-sessions! pool organization-id)) + (session/clear-organization-sso-sessions! pool organization-id) + (let [request (-> params meta ::http/request) + context (-> (audit/prepare-context-from-request request) + (assoc :request-id (::rpc/request-id params)))] + (audit/submit cfg {:type "action" + :name "organization-sso-token-revoked" + :profile-id uuid/zero + :ip-addr (::rpc/ip-addr params) + :props {:organization-id organization-id} + :context (d/without-nils context)}))) (notifications/notify-organization-change-sso cfg organization-id) (when announce-activation (neh/send-organization-setup-sso-emails! cfg organization-id)) diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 90e1aa18f4..55bab1a134 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.time :as ct] + [app.common.uuid :as uuid] [app.config :as cf] [app.http.session :as session] [app.setup :as-alias setup] @@ -524,6 +525,7 @@ (t/is (.contains loc "error=unable-to-auth"))))))) (t/deftest organization-sso-callback-success-emits-succeeded + ;; No session on the request → auth-succeeded only (no token-issued). (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" :organization-id test-organization-id}) @@ -538,6 +540,36 @@ (t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events))) (t/is (= test-organization-id (get-in (first @events) [:props :organization-id]))))))) +(t/deftest organization-sso-callback-success-with-session-emits-token-issued + (let [manager (session/inmemory-manager) + cfg (assoc (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + ::session/manager manager) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (default-request cfg :state state) + events (atom []) + sess {:id (uuid/next) + :profile-id test-profile-id + :props {}}] + (with-redefs [app.nitrate/call (constantly {:active true}) + app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"}) + app.auth.oidc/get-info (constantly {}) + app.http.session/get-session (constantly sess) + app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (let [result (#'oidc/callback-handler cfg request) + names (mapv :name @events) + stored (session/read-session manager (:id sess))] + (t/is (= "https://penpot.example.com/#/workspace" (redirect-location result))) + (t/is (= ["organization-sso-token-issued" + "organization-sso-auth-succeeded"] + names)) + (t/is (= test-organization-id + (get-in (first @events) [:props :organization-id]))) + (t/is (= test-profile-id (:profile-id (first @events)))) + (t/is (= #{:organization-id} + (set (keys (:props (first @events)))))) + (t/is (contains? (get-in stored [:props :sso]) test-organization-id)))))) + (t/deftest organization-sso-callback-error-emits-failed (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" diff --git a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj index 146f0da10b..1fb3efd9e5 100644 --- a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -113,12 +113,13 @@ http-mock {:target 'app.http.client/req :return {:status 204}}] (insert-audit-row! (:id prof) "create-project") (insert-audit-row! (:id prof) "create-organization-attribute") + (insert-audit-row! (:id prof) "auditlog-exported") (insert-audit-row! (:id prof) "unrelated-event") (run-both! {:uri archive-uri}) (t/is (:called? @admin-console-mock)) (t/is (:called? @http-mock)) (let [[_ _ params] (:call-args @admin-console-mock)] - (t/is (= #{"create-project" "create-organization-attribute"} + (t/is (= #{"create-project" "create-organization-attribute" "auditlog-exported"} (into #{} (map :name) (:events params))))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index be36f84680..500b241279 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -576,6 +576,34 @@ (t/is (= :organization-change-sso (:type msg))) (t/is (= organization-id (:organization-id msg)))))) +(t/deftest notify-organization-sso-change-emits-token-revoked-when-updated-props + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [organization-id (uuid/random) + out (with-redefs [mbus/pub! (constantly nil)] + (th/management-command! {::th/type :notify-organization-sso-change + :organization-id organization-id + :updated-props true + :announce-activation false})) + revoked (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "organization-sso-token-revoked" (:name %))))] + (t/is (th/success? out)) + (t/is (= 1 (count revoked))) + (t/is (= uuid/zero (:profile-id (first revoked)))) + (t/is (= {:organization-id organization-id} (:props (first revoked)))))) + + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [out (with-redefs [mbus/pub! (constantly nil)] + (th/management-command! {::th/type :notify-organization-sso-change + :organization-id (uuid/random) + :updated-props false + :announce-activation false})) + revoked (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "organization-sso-token-revoked" (:name %))))] + (t/is (th/success? out)) + (t/is (empty? revoked))))) + (t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events ;; --- Deferred owned-organizations: nil during setup, filled before RPC --- (let [owned-organizations-ref (atom nil)] diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index ed0a12fb5b..b1bab1acb6 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -527,7 +527,8 @@ (t/is (th/success? out)) (t/is (not (contains? (:result out) :organization-invitation-audit))))) - (let [event (organization-event)] + (let [event (organization-event) + from-event (first (emitted-events "accept-organization-invitation-from"))] (t/is (= organization-id (get-in event [:props :organization-id]))) (t/is (= (:id invitee) (get-in event [:props :profile-id]))) (t/is (= (:id inviter) (get-in event [:props :invited-by]))) @@ -541,11 +542,39 @@ (t/is (not (contains? (:props event) :invitation-origin))) (t/is (not (contains? (:context event) :event-origin))) (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation-from")))) + (t/is (= (:id inviter) (:profile-id from-event))) + (t/is (= (:props event) (:props from-event))) (t/is (not-any? #(contains? #{"accept-team-invitation" "accept-team-invitation-from"} (:name (second %))) (:call-args-list @audit-mock)))) + ;; Legacy org invitation with no :created-by → invitee event only, no -from. + (th/reset-mock! audit-mock) + (db/insert! (:app.db/pool th/*system*) + :team-invitation + {:org-id organization-id + :email-to (:email invitee) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call + (fn [_cfg method _params] + (case method + :get-organization-membership {:organization-id organization-id + :is-member false} + :get-organization-members [(:id inviter) (uuid/random) (uuid/random)] + nil)) + teams/initialize-user-in-organization + (fn [& _] default-team-id)] + (let [out (verify! direct-token)] + (t/is (th/success? out)))) + + (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) + (t/is (empty? (emitted-events "accept-organization-invitation-from"))) + (th/reset-mock! audit-mock) (db/insert! (:app.db/pool th/*system*) :team-invitation @@ -569,8 +598,9 @@ (t/is (th/success? out)) (t/is (not (contains? (:result out) :organization-invitation-audit))))) - (let [events (mapv second (:call-args-list @audit-mock)) - event (organization-event)] + (let [events (mapv second (:call-args-list @audit-mock)) + event (organization-event) + org-from-event (first (emitted-events "accept-organization-invitation-from"))] (t/is (some #(= "accept-team-invitation" (:name %)) events)) (t/is (some #(= "accept-team-invitation-from" (:name %)) events)) (t/is (= (:id team) (get-in event [:props :team-id]))) @@ -583,7 +613,10 @@ (t/is (= 5 (get-in event [:props :organization-member-count-before]))) (t/is (not (contains? (:props event) :invitation-origin))) (t/is (not (contains? (:context event) :event-origin))) - (t/is (= 1 (count (emitted-events "accept-organization-invitation"))))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation-from")))) + (t/is (= (:id inviter) (:profile-id org-from-event))) + (t/is (= (:props event) (:props org-from-event)))) (let [from-event (first (emitted-events "accept-team-invitation-from"))] (t/is (= (:id team) (get-in from-event [:props :team-id]))) From 22fe9984f13dc497c0b8062ea15eb94e49d04fb7 Mon Sep 17 00:00:00 2001 From: Stas Haas Date: Tue, 20 Oct 2026 12:02:52 +0200 Subject: [PATCH 123/325] :globe_with_meridians: Add translations for: German Currently translated at 83.6% (1904 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/ --- frontend/translations/de.po | 692 +++++++++++++++++++++++++++++++++++- 1 file changed, 691 insertions(+), 1 deletion(-) diff --git a/frontend/translations/de.po b/frontend/translations/de.po index 93c0b034cf..ce907e7e35 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-10-16 10:51+0000\n" +"PO-Revision-Date: 2026-10-21 09:52+0000\n" "Last-Translator: Stas Haas \n" "Language-Team: German \n" @@ -7336,3 +7336,693 @@ msgid "dashboard.no-organization-allows-create-team.message" msgstr "" "Sie haben keine Berechtigung, Teams zu einer Ihrer Organisationen " "hinzuzufügen." + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "Original" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "Noch keine Organisationen." + +#: src/app/main/ui/dashboard/team_form.cljs:152 +msgid "dashboard.no-permission-create-team.message" +msgstr "" +"Sie dürfen in der Organisation %s keine Teams erstellen. Wenn Sie mehr " +"Informationen benötigen, wenden Sie sich an den Eigentümer der Organisation." + +#: src/app/main/ui/dashboard/team_form.cljs:154 +msgid "dashboard.no-permission-delete-team.message" +msgstr "" +"Sie dürfen keine Teams löschen, die zur Organisation %s gehören. Wenn Sie " +"mehr Informationen benötigen, wenden Sie sich an den Eigentümer der " +"Organisation." + +#: src/app/main/ui/dashboard/team_form.cljs:160 +msgid "dashboard.no-permission-move-team.message" +msgstr "" +"Sie dürfen keine Teams verschieben, die zur Organisation %s gehören. Wenn " +"Sie mehr Informationen benötigen, wenden Sie sich an den Eigentümer der " +"Organisation." + +#: src/app/main/data/dashboard.cljs:703 +msgid "dashboard.organization-deleted" +msgstr "Die Organisation %s wurde gelöscht." + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:208, src/app/main/ui/dashboard/organization_team_switch.cljs:209 +msgid "dashboard.other-teams" +msgstr "Weitere Teams" + +#: src/app/main/ui/dashboard/file_menu.cljs:38, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:58, src/app/main/ui/dashboard/organization_team_switch.cljs:57, src/app/main/ui/dashboard/projects.cljs:50, src/app/main/ui/dashboard/projects.cljs:364, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/team.cljs:647, src/app/main/ui/dashboard/team.cljs:1257, src/app/main/ui/dashboard/team.cljs:1537, src/app/main/ui/dashboard/team.cljs:1644 +msgid "dashboard.personal-projects" +msgstr "Persönliche Projekte" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:180 +msgid "dashboard.section.organizations" +msgstr "Organisationen" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:249 +msgid "dashboard.section.teams" +msgstr "Teams" + +#: src/app/main/data/nitrate.cljs:517 +msgid "dashboard.select-organization-modal.accept" +msgstr "ZUR ORGANISATION HINZUFÜGEN" + +#: src/app/main/data/nitrate.cljs:515 +msgid "dashboard.select-organization-modal.choose" +msgstr "Organisation auswählen:" + +#: src/app/main/ui/dashboard/team.cljs:1041 +msgid "dashboard.select-organization-modal.external-invitations-warning" +msgstr "" +"„%s“ erlaubt nur Mitgliedern der Organisation, ihren Teams beizutreten. " +"Ausstehende Einladungen an Personen außerhalb der Organisation werden " +"widerrufen." + +#: src/app/main/ui/dashboard/team.cljs:1037 +msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" +msgstr "Ausstehende Einladungen an externe Benutzer werden storniert." + +#: src/app/main/data/nitrate.cljs:583 +msgid "dashboard.select-organization-modal.permission-info" +msgstr "" +"Hier finden Sie die Organisationen, denen Sie angehören und in die Sie das " +"Team verschieben dürfen." + +#: src/app/main/data/nitrate.cljs:507 +msgid "dashboard.select-organization-modal.permission-info-add" +msgstr "" +"Hier finden Sie die Organisationen, denen Sie angehören und in denen Sie " +"Teams erstellen oder hinzufügen dürfen." + +#: src/app/main/data/nitrate.cljs:516 +msgid "dashboard.select-organization-modal.select" +msgstr "Organisation auswählen" + +#: src/app/main/data/nitrate.cljs:514, src/app/main/data/nitrate.cljs:534, src/app/main/ui/dashboard/team_form.cljs:157 +msgid "dashboard.select-organization-modal.title" +msgstr "Team zu einer Organisation hinzufügen" + +#: src/app/main/data/common.cljs:266 +#, backend +msgid "dashboard.team-belong-organization" +msgstr "Dieses Team gehört jetzt zur Organisation %s" + +#: src/app/main/data/common.cljs:267 +#, backend +msgid "dashboard.team-no-longer-belong-organization" +msgstr "Dieses Team gehört nicht mehr zur Organisation %s" + +#: src/app/main/ui/dashboard/team.cljs:1676 +msgid "dashboard.team-organization" +msgstr "Teamorganisation" + +#: src/app/main/ui/dashboard/team.cljs:1710 +msgid "dashboard.team-organization.add" +msgstr "Zu einer Organisation hinzufügen" + +#: src/app/main/data/nitrate.cljs:533, src/app/main/data/nitrate.cljs:590 +msgid "dashboard.team-organization.add.no-valid-organizations" +msgstr "" +"Sie haben keine Berechtigung, dieses Team zu einer Ihrer Organisationen " +"hinzuzufügen." + +#: src/app/main/ui/dashboard/team.cljs:1699 +msgid "dashboard.team-organization.change" +msgstr "Organisation des Teams ändern" + +#: src/app/main/ui/dashboard/team.cljs:948 +msgid "dashboard.team-organization.disabled-organization-tooltip" +msgstr "Es können nur Personen aus Ihrer Organisation eingeladen werden." + +#: src/app/main/ui/dashboard/team.cljs:1706 +msgid "dashboard.team-organization.none" +msgstr "Dieses Team gehört zu keiner Organisation" + +#: src/app/main/ui/dashboard/team.cljs:1702 +msgid "dashboard.team-organization.remove" +msgstr "Team aus der Organisation entfernen" + +#: src/app/main/data/dashboard.cljs:671 +#, backend +msgid "dashboard.user-no-longer-belong-organization" +msgstr "Sie sind kein Mitglied der Organisation %s mehr" + +#: src/app/main/ui/settings/options.cljs:109 +msgid "dashboard.webgl-switch.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/options.cljs:110 +msgid "dashboard.webgl-switch.description" +msgstr "" +"Die WebGL-Darstellung kann die Leistung verbessern, befindet sich aber in " +"der Beta und kann weniger stabil sein. Zwischen Arbeitsfläche, Exporten (SVG/" +"PDF) und der Ansicht können sichtbare Unterschiede auftreten." + +#: src/app/main/ui/settings/options.cljs:113 +msgid "dashboard.webgl-switch.disabled" +msgstr "Deaktiviert" + +#: src/app/main/ui/settings/options.cljs:113 +msgid "dashboard.webgl-switch.enabled" +msgstr "Aktiviert" + +#: src/app/main/ui/settings/options.cljs:116 +msgid "dashboard.webgl-switch.feedback" +msgstr "Feedback geben" + +#: src/app/main/ui/settings/options.cljs:112 +msgid "dashboard.webgl-switch.status" +msgstr "Status" + +#: src/app/main/ui/settings/options.cljs:108 +msgid "dashboard.webgl-switch.title" +msgstr "WebGL-Darstellung" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:55, src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs:54, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:118, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:134 +msgid "ds.inputs.token-field.no-active-token-option" +msgstr "" +"Das Token {%s} ist in keinem aktiven Set oder hat einen ungültigen Wert." + +#: src/app/main/data/workspace/libraries.cljs:1691 +msgid "errors.circular-library-reference" +msgstr "" +"Bibliothek kann nicht hinzugefügt werden: Das würde eine zirkuläre " +"Abhängigkeit erzeugen" + +#: src/app/main/ui/settings/integrations.cljs:93, src/app/util/clipboard.cljs:248 +msgid "errors.clipboard-api-unavailable" +msgstr "" +"Die Zwischenablage-API ist nicht verfügbar. Stellen Sie Penpot über HTTPS " +"bereit, damit der Zugriff auf die Zwischenablage funktioniert" + +#: src/app/util/clipboard.cljs:245 +msgid "errors.clipboard-permission-denied" +msgstr "" +"Zugriff auf die Zwischenablage verweigert. Erlauben Sie in Ihrem Browser den " +"Zugriff auf die Zwischenablage, um Inhalte einzufügen" + +#: src/app/main/ui/dashboard/fonts.cljs:341 +msgid "errors.font-family-invalid-chars" +msgstr "" +"Der Name der Schriftfamilie darf nur Buchstaben, Ziffern, Leerzeichen, " +"Bindestriche, Unterstriche und Punkte enthalten." + +#: src/app/main/ui/static.cljs:88 +msgid "errors.invite-email-mismatch" +msgstr "" +"Diese Einladung gilt für eine andere E-Mail-Adresse. Melden Sie sich ab und " +"mit dem eingeladenen Konto an, oder bitten Sie den Teameigentümer um eine " +"neue Einladung." + +#: src/app/main/ui/static.cljs:92 +msgid "errors.invite-expired" +msgstr "" +"Diese Einladung ist abgelaufen. Bitten Sie den Teameigentümer, Ihnen eine " +"neue zu senden." + +#: src/app/main/data/nitrate.cljs:209 +msgid "errors.organization-leave.no-valid-teams" +msgstr "" +"Beim Verlassen der Organisation ist ein Problem aufgetreten. Bitte versuchen " +"Sie es erneut." + +#: src/app/main/data/nitrate.cljs:210 +msgid "errors.organization-leave.organization-owner-cannot-leave" +msgstr "Der Eigentümer der Organisation kann die Organisation nicht verlassen." + +#: src/app/main/ui/auth/verify_token.cljs:125 +msgid "errors.organization-not-found" +msgstr "Diese Organisation existiert nicht" + +#: src/app/main/errors.cljs:538 +msgid "errors.save-failed" +msgstr "" +"Die automatische Speicherung funktioniert wegen eines Fehlers nicht. " +"Kontaktieren Sie den Support, um den Fehler zu melden, und laden Sie neu, um " +"mit Ihrer zuletzt gespeicherten Version weiterzuarbeiten." + +#: src/app/main/data/nitrate.cljs:191 +msgid "errors.team-leave.only-owner-can-delete" +msgstr "Nur der Eigentümer eines Teams kann es löschen." + +#: ../common/src/app/common/types/team.cljc:27 +msgid "errors.team-name-invalid-chars" +msgstr "" +"Der Teamname darf keines der folgenden Zeichen enthalten: '.', ':' oder '/'" + +#: ../common/src/app/common/files/tokens.cljc:351 +msgid "errors.token-theme-not-existing-sets" +msgstr "Das Theme verweist auf Sets, die nicht existieren: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:50, ../common/src/app/common/files/tokens.cljc:230 +msgid "errors.tokens.circular-reference" +msgstr "Das Token hat einen zirkulären Verweis" + +#: src/app/main/data/workspace/tokens/errors.cljs:116, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:245 +msgid "errors.tokens.composite-line-height-needs-font-size" +msgstr "" +"Die Zeilenhöhe hängt von der Schriftgröße ab. Fügen Sie eine Schriftgröße " +"hinzu, um den aufgelösten Wert zu erhalten." + +#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 +msgid "errors.tokens.empty-field" +msgstr "Dieses Feld darf nicht leer sein" + +#: src/app/main/data/workspace/tokens/errors.cljs:108 +msgid "errors.tokens.invalid-font-family-token-value" +msgstr "" +"Ungültiger Token-Wert: Sie können nur auf ein Schriftfamilien-Token verweisen" + +#: src/app/main/data/workspace/tokens/errors.cljs:132 +msgid "errors.tokens.invalid-token-value-shadow" +msgstr "" +"Ungültiger Wert: Er muss auf ein zusammengesetztes Schatten-Token verweisen." + +#: src/app/main/data/workspace/tokens/errors.cljs:68, src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs:126 +msgid "errors.tokens.name-collision" +msgstr "" +"Der Gruppenname von %s kollidiert mit einem Token desselben Namens in einem " +"anderen aktiven Set." + +#: src/app/main/data/workspace/tokens/errors.cljs:128 +msgid "errors.tokens.shadow-spread-range" +msgstr "Die Schattenausbreitung muss größer oder gleich 0 sein." + +#: src/app/main/ui/auth/recovery.cljs:44, src/app/main/ui/auth/register.cljs:125, src/app/main/ui/settings/password.cljs:42 +msgid "errors.weak-password" +msgstr "Das Passwort erfüllt die Anforderungen nicht" + +#: src/app/main/ui/auth/recovery.cljs:37, src/app/main/ui/auth/register.cljs:119, src/app/main/ui/settings/password.cljs:35 +#, backend +msgid "errors.weak-password.insufficient-digits" +msgstr "Mindestens 1 Ziffer" + +#: src/app/main/ui/auth/recovery.cljs:38, src/app/main/ui/auth/register.cljs:120, src/app/main/ui/settings/password.cljs:36 +#, backend +msgid "errors.weak-password.insufficient-lowercase" +msgstr "Mindestens 1 Kleinbuchstabe" + +#: src/app/main/ui/auth/recovery.cljs:40, src/app/main/ui/auth/register.cljs:122, src/app/main/ui/settings/password.cljs:38 +#, backend +msgid "errors.weak-password.insufficient-special" +msgstr "Mindestens 1 Sonderzeichen" + +#: src/app/main/ui/auth/recovery.cljs:39, src/app/main/ui/auth/register.cljs:121, src/app/main/ui/settings/password.cljs:37 +#, backend +msgid "errors.weak-password.insufficient-uppercase" +msgstr "Mindestens 1 Großbuchstabe" + +#: src/app/main/ui/auth/recovery.cljs:36, src/app/main/ui/auth/register.cljs:118, src/app/main/ui/settings/password.cljs:34 +#, backend +msgid "errors.weak-password.too-short" +msgstr "Mindestens 8 Zeichen" + +#: src/app/main/ui/exports/files.cljs:183 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "Verknüpfte Bibliotheks-Assets werden nicht in die Datei aufgenommen." + +#: src/app/main/ui/exports/files.cljs:177 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Assets als einfache Objekte behandeln" + +#: src/app/main/ui/exports/files.cljs:181 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Dateien mit verknüpften Bibliotheken werden in den Export aufgenommen, die " +"Verknüpfung bleibt erhalten. " + +#: src/app/main/ui/exports/files.cljs:175 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Datei und Bibliotheken exportieren" + +#: src/app/main/ui/exports/files.cljs:184 +msgid "files-export-modal.options.link-later.message" +msgstr "" +"Beim Import können Sie vorhandene Bibliotheken mit passenden Namen " +"verknüpfen." + +#: src/app/main/ui/exports/files.cljs:178 +msgid "files-export-modal.options.link-later.title" +msgstr "Passende Bibliotheken beim Import verknüpfen" + +#: src/app/main/ui/exports/files.cljs:182 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Ihre Datei wird exportiert, wobei die Bibliotheks-Assets in die lokale " +"Bibliothek übernommen werden." + +#: src/app/main/ui/exports/files.cljs:176 +msgid "files-export-modal.options.merge-libraries.title" +msgstr "Bibliotheks-Assets in die Datei einbetten" + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:161 +msgid "import-shortcuts.apply" +msgstr "Übernehmen" + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:102 +msgid "import-shortcuts.diff-modal-text" +msgstr "" +"Die folgenden Tastaturkürzel werden durch die importierte Konfiguration " +"geändert." + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:98 +msgid "import-shortcuts.diff-modal-title" +msgstr "Tastaturkürzel importieren" + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:148 +msgid "import-shortcuts.no-changes" +msgstr "" +"Zwischen den aktuellen und den importierten Tastaturkürzeln wurden keine " +"Unterschiede gefunden." + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:594 +msgid "inspect.attributes.typography.mixed-font-family" +msgstr "Gemischte Schriftfamilie" + +#: src/app/main/ui/inspect/attributes/text.cljs:117 +msgid "inspect.attributes.typography.text-decoration.strikethrough" +msgstr "Durchgestrichen" + +#: src/app/main/ui/inspect/right_sidebar.cljs:176 +msgid "inspect.tabs-switcher-label" +msgstr "Inspektionsansicht" + +#: src/app/main/ui/settings/integrations.cljs:616 +msgid "integrations.access-tokens.create" +msgstr "Neues Zugangstoken erstellen" + +#: src/app/main/ui/settings/integrations.cljs:632 +msgid "integrations.access-tokens.empty.add-one" +msgstr "" +"Drücken Sie die Schaltfläche „Neues Zugangstoken erstellen“, um eines zu " +"erzeugen." + +#: src/app/main/ui/settings/integrations.cljs:631 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "Sie haben noch keine Token." + +#: src/app/main/ui/settings/integrations.cljs:606 +msgid "integrations.access-tokens.personal" +msgstr "Persönliche Zugangstoken" + +#: src/app/main/ui/settings/integrations.cljs:611 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Persönliche Zugangstoken funktionieren als Alternative zur Anmeldung mit " +"Benutzername und Passwort und können einer Anwendung Zugriff auf die interne " +"Penpot-API geben" + +#: src/app/main/ui/settings/integrations.cljs:68 +msgid "integrations.copy-to-clipboard" +msgstr "In die Zwischenablage kopieren" + +#: src/app/main/ui/settings/integrations.cljs:253 +msgid "integrations.create-access-token.title" +msgstr "Zugangstoken erstellen" + +#: src/app/main/ui/settings/integrations.cljs:252 +msgid "integrations.create-access-token.title.created" +msgstr "Zugangstoken erstellt" + +#: src/app/main/ui/settings/integrations.cljs:371 +msgid "integrations.delete-token.accept" +msgstr "Token löschen" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.delete-token.message" +msgstr "Sind Sie sicher, dass Sie dieses Token löschen möchten?" + +#: src/app/main/ui/settings/integrations.cljs:369 +msgid "integrations.delete-token.title" +msgstr "Token löschen" + +#: src/app/main/ui/settings/integrations.cljs:213 +msgid "integrations.expiration-180-days" +msgstr "180 Tage" + +#: src/app/main/ui/settings/integrations.cljs:210 +msgid "integrations.expiration-30-days" +msgstr "30 Tage" + +#: src/app/main/ui/settings/integrations.cljs:211 +msgid "integrations.expiration-60-days" +msgstr "60 Tage" + +#: src/app/main/ui/settings/integrations.cljs:212 +msgid "integrations.expiration-90-days" +msgstr "90 Tage" + +#: src/app/main/ui/settings/integrations.cljs:208 +msgid "integrations.expiration-date.label" +msgstr "Ablaufdatum" + +#: src/app/main/ui/settings/integrations.cljs:209 +msgid "integrations.expiration-never" +msgstr "Nie" + +#: src/app/main/ui/settings/integrations.cljs:393 +msgid "integrations.expired-on" +msgstr "Abgelaufen am %s" + +#: src/app/main/ui/settings/integrations.cljs:394 +msgid "integrations.expires-on" +msgstr "Läuft ab am %s" + +#: src/app/main/ui/settings/integrations.cljs:294 +msgid "integrations.generate-mcp-key.title" +msgstr "MCP-Schlüssel erzeugen" + +#: src/app/main/ui/settings/integrations.cljs:292 +msgid "integrations.generate-mcp-key.title.created" +msgstr "MCP-Schlüssel erzeugt" + +#: src/app/main/ui/settings/integrations.cljs:193 +msgid "integrations.info.mcp-server" +msgstr "" +"Der Penpot-MCP-Server ermöglicht MCP-Clients, direkt mit Penpot-" +"Designdateien zu arbeiten." + +#: src/app/main/ui/settings/integrations.cljs:119 +msgid "integrations.mcp-key.will-expire" +msgstr "Der MCP-Schlüssel läuft am %s ab" + +#: src/app/main/ui/settings/integrations.cljs:122 +msgid "integrations.mcp-key.will-not-expire" +msgstr "Der MCP-Schlüssel hat kein Ablaufdatum" + +#: src/app/main/ui/settings/integrations.cljs:498 +msgid "integrations.mcp-server.description" +msgstr "" +"Der Penpot-MCP-Server ermöglicht MCP-Clients, direkt mit Penpot-" +"Designdateien zu arbeiten." + +#: src/app/main/ui/settings/integrations.cljs:581 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "MCP-Clients einrichten" + +#: src/app/main/ui/settings/integrations.cljs:568 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "" +"Das ist die Server-URL, die Sie brauchen, um Ihren MCP-Client mit dem Penpot-" +"MCP-Server zu verbinden." + +#: src/app/main/ui/settings/integrations.cljs:548 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "MCP-Schlüssel neu erzeugen" + +#: src/app/main/ui/settings/integrations.cljs:541 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "MCP-Schlüssel" + +#: src/app/main/ui/settings/integrations.cljs:549 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "Der MCP-Schlüssel wird für die Einrichtung des MCP-Clients benötigt" + +#: src/app/main/ui/settings/integrations.cljs:504 +msgid "integrations.mcp-server.status" +msgstr "Status" + +#: src/app/main/ui/settings/integrations.cljs:524 +msgid "integrations.mcp-server.status.disabled" +msgstr "Deaktiviert" + +#: src/app/main/ui/settings/integrations.cljs:523 +msgid "integrations.mcp-server.status.enabled" +msgstr "Aktiviert" + +#: src/app/main/ui/settings/integrations.cljs:514 +msgid "integrations.mcp-server.status.expired.0" +msgstr "" +"Der MCP-Schlüssel für die Verbindung zum MCP-Server ist abgelaufen. Die " +"Verbindung kann deshalb nicht hergestellt werden." + +#: src/app/main/ui/settings/integrations.cljs:519 +msgid "integrations.mcp-server.status.expired.1" +msgstr "" +"Erzeugen Sie den MCP-Schlüssel neu und aktualisieren Sie die Client-" +"Konfiguration mit dem neuen Schlüssel." + +#: src/app/main/ui/settings/integrations.cljs:489 +msgid "integrations.mcp-server.title" +msgstr "MCP-Server" + +#: src/app/main/ui/settings/integrations.cljs:493 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:200 +msgid "integrations.name.label" +msgstr "Name" + +#: src/app/main/ui/settings/integrations.cljs:201 +msgid "integrations.name.placeholder" +msgstr "Der Name hilft zu erkennen, wofür das Token gedacht ist" + +#: src/app/main/ui/settings/integrations.cljs:392 +msgid "integrations.no-expiration" +msgstr "Kein Ablaufdatum" + +#: src/app/main/ui/settings/integrations.cljs:478 +msgid "integrations.notification.success.copied-link" +msgstr "Link in die Zwischenablage kopiert" + +#: src/app/main/ui/settings/integrations.cljs:241, src/app/main/ui/settings/integrations.cljs:274, src/app/main/ui/settings/integrations.cljs:319 +msgid "integrations.notification.success.created" +msgstr "Token erfolgreich erstellt" + +#: src/app/main/ui/settings/integrations.cljs:87 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "MCP-Schlüssel kopiert" + +#: src/app/main/ui/settings/integrations.cljs:444 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "MCP-Server deaktiviert" + +#: src/app/main/ui/settings/integrations.cljs:443 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "MCP-Server aktiviert" + +#: src/app/main/ui/settings/integrations.cljs:88 +msgid "integrations.notification.success.token-copied" +msgstr "Token kopiert" + +#: src/app/main/ui/settings/integrations.cljs:337 +msgid "integrations.regenerate-mcp-key.info" +msgstr "" +"Wenn Sie den MCP-Schlüssel neu erzeugen, wird der aktuelle sofort " +"widerrufen. Anwendungen, die ihn verwenden, funktionieren dann nicht mehr." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.regenerate-mcp-key.title" +msgstr "MCP-Schlüssel neu erzeugen" + +#: src/app/main/ui/settings/integrations.cljs:334 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "MCP-Schlüssel neu erzeugt" + +#: src/app/main/ui/settings/integrations.cljs:655 +msgid "integrations.title" +msgstr "Integrationen" + +#: src/app/main/ui/settings/integrations.cljs:108 +msgid "integrations.token.info.non-recuperable" +msgstr "" +"Dieses eindeutige Token kann nicht wiederhergestellt werden. Wenn Sie es " +"verlieren, müssen Sie ein neues erstellen." + +#: src/app/main/ui/settings/integrations.cljs:120 +msgid "integrations.token.will-expire" +msgstr "Das Token läuft am %s ab" + +#: src/app/main/ui/settings/integrations.cljs:123 +msgid "integrations.token.will-not-expire" +msgstr "Das Token hat kein Ablaufdatum" + +#: src/app/main/ui/inspect/attributes/blur.cljs:31, src/app/main/ui/inspect/styles/style_box.cljs:32, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:262, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:274 +msgid "labels.blur-effects" +msgstr "Weichzeichner" + +#: src/app/main/ui/dashboard/sidebar.cljs:553 +msgid "labels.check-for-updates" +msgstr "Nach Updates suchen" + +#: src/app/main/ui/dashboard/sidebar.cljs:552 +msgid "labels.checking-for-updates" +msgstr "Suche nach Updates…" + +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:111, src/app/main/ui/settings/restore_shortcuts_modal.cljs:102 +msgid "labels.current" +msgstr "Aktuell" + +#: src/app/main/ui/settings/profile.cljs:116 +msgid "labels.delete-profile-photo.message" +msgstr "Sind Sie sicher, dass Sie Ihr Profilfoto löschen möchten?" + +#: src/app/main/ui/settings/profile.cljs:115 +msgid "labels.delete-profile-photo.title" +msgstr "Profilfoto löschen" + +#: src/app/main/ui/dashboard/fonts.cljs:283 +msgid "labels.download-simple" +msgstr "Herunterladen" + +#: src/app/main/ui/exports/files.cljs:217 +msgid "labels.downloading-file" +msgstr "Datei wird heruntergeladen…" + +#: src/app/main/data/workspace/versions.cljs:314 +msgid "labels.exit" +msgstr "Beenden" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1284 +msgid "labels.flex-layout" +msgstr "Flex-Layout" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1288 +msgid "labels.grid-layout" +msgstr "Raster-Layout" + +#: src/app/main/ui/settings/sidebar.cljs:134 +msgid "labels.integrations" +msgstr "Integrationen" + +#: src/app/main/ui/static.cljs:362 +msgid "labels.nitrate-not-configured.desc-message" +msgstr "" +"Diese Instanz braucht einen Einrichtungsschritt aus der neuesten Version." + +#: src/app/main/ui/static.cljs:365 +msgid "labels.nitrate-not-configured.learn-more" +msgstr "Sehen Sie sich unsere" + +#: src/app/main/ui/static.cljs:360 +msgid "labels.nitrate-not-configured.main-message" +msgstr "Penpot kann noch nicht starten." + +#: src/app/main/ui/static.cljs:370 +msgid "labels.nitrate-not-configured.technical-guide" +msgstr "technische Anleitung" + +#: src/app/main/ui/static.cljs:350 +msgid "labels.nitrate-unavailable.main-message" +msgstr "" +"Penpot ist wegen eines Systemproblems vorübergehend nicht verfügbar. Bitte " +"versuchen Sie es in Kürze erneut." + +#: src/app/main/ui/dashboard/team.cljs:855 +msgid "labels.no-invitations-gather-people" +msgstr "Holen Sie Ihre Leute zusammen und erschaffen Sie gemeinsam Großes." + +#: src/app/main/ui/components/select.cljs:256 +msgid "labels.no-matches" +msgstr "Keine Treffer" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:994 +msgid "labels.open" +msgstr "Öffnen" + +#: src/app/main/ui/dashboard/import.cljs:703 +msgid "labels.skip" +msgstr "Überspringen" From 858d5dad9c2dfd7fdb02c6f26c03e080576bf416 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 12:32:09 +0200 Subject: [PATCH 124/325] :recycle: Use DS buttons in the debug shape panel (#12484) * :recycle: Use DS buttons in the debug shape panel The object and tree dump buttons of the debug shape panel were raw buttons with the browser default look. Use DS secondary buttons. The DS buttons are taller, so the title row centers the shape name. Add a visual test that turns on the shape-panel debug option and shows the title row of the selected shape. AI-assisted-by: claude-opus-5-5 * :recycle: Move debug shape panel styles off the deprecated styles Move debug_shape_info.scss to DS spacing tokens, borders and logical properties, resolve the panel background alias, and drop the refactor import. The two font sizes match no DS typography, so they stay with a TODO, and the unused shape-info-title class is commented out for a manual check. The panel looks the same as with the previous commit. AI-assisted-by: claude-opus-5-5 * :bug: Let the debugging tools options scroll The debugging tools panel lists 32 options in the left sidebar, whose content area hides what overflows. On a short window the last options were cut off with no way to reach them. Make the options list fill the space under the title and scroll on its own. AI-assisted-by: claude-opus-5-5 * :recycle: Move debugging tools panel styles to DS tokens Move debug.scss spacing, size and the panel background alias to DS tokens and logical properties. The refactor import stays only for the checkbox placeholders, until the checkboxes move to the DS. The panel looks the same as before. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 29 +++++++++++++ .../app/main/ui/workspace/sidebar/debug.scss | 14 +++++-- .../workspace/sidebar/debug_shape_info.cljs | 9 +++- .../workspace/sidebar/debug_shape_info.scss | 42 +++++++++++-------- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index f18514cc9d..4ff50815a0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -389,6 +389,35 @@ test.describe("Assets tab", () => { }); }); +test.describe("Debug shape panel", () => { + test("Shows the debug info of the selected shape", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickLeafLayer("Board"); + await page.getByRole("button", { name: "Debugging tool" }).click(); + await page.getByText("shape-panel", { exact: true }).click(); + await expect( + workspace.rightSidebar.getByText("Board", { exact: true }), + ).toBeVisible(); + await page.mouse.move(640, 600); + // Only the title row with the dump buttons: the attributes list below + // holds ids that change on every run. + const title = workspace.rightSidebar + .getByText("Board", { exact: true }) + .locator(".."); + await expect(title).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Guides", () => { test("Shows the advanced options of a square guide", async ({ page }) => { // A taller window keeps the whole Design tab visible: a scrolled diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.scss b/frontend/src/app/main/ui/workspace/sidebar/debug.scss index da70295d97..ced6252661 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.scss @@ -4,26 +4,34 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_sizes.scss" as *; + +// Only for the checkbox placeholders (%input-checkbox, %checkbox-icon), +// until the checkboxes move to the DS. @use "refactor/common-refactor.scss" as deprecated; .debug-panel { display: flex; flex-direction: column; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } .debug-panel-title { margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); } +// The options list scrolls under the title when it does not fit. .debug-panel-inner { - padding: deprecated.$s-16 deprecated.$s-8; + flex: 1; + min-block-size: 0; + overflow-y: auto; + padding: var(--sp-l) var(--sp-s); } .checkbox-wrapper { @extend %input-checkbox; - height: deprecated.$s-32; + block-size: $sz-32; padding: 0; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs index 92c9ae2e2b..d2cc9888af 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs @@ -13,6 +13,7 @@ [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] [debug :as dbg] [rumext.v2 :as mf])) @@ -130,8 +131,12 @@ [:div {:class (stl/css :attrs-container) :key (dm/str "shape" idx)} [:div {:class (stl/css :shape-title)} [:div {:class (stl/css :shape-name)} (:name current)] - [:button {:on-click #(debug/dump-object (dm/str (:id current)))} "object"] - [:button {:on-click #(debug/dump-subtree (dm/str (:id current)) true)} "tree"]] + [:> button* {:variant "secondary" + :on-click #(debug/dump-object (dm/str (:id current)))} + "object"] + [:> button* {:variant "secondary" + :on-click #(debug/dump-subtree (dm/str (:id current)) true)} + "tree"]] [:div {:class (stl/css :shape-attrs)} (let [attrs (get-attrs current)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index c849565bb5..9f770611f7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -4,34 +4,42 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; .shape-info { display: flex; flex-direction: column; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); color: var(--color-foreground-primary); - font-size: deprecated.$fs-12; + + // TODO: No exact DS typography match found. + // Closest match: body-small. + font-size: px2rem(12); user-select: text; } -.shape-info-title { - margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); -} +// TODO: Unused in debug_shape_info.cljs; check by hand before removing. +// .shape-info-title { +// margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); +// } .attrs-container { - padding: deprecated.$s-16 deprecated.$s-8; + padding: var(--sp-l) var(--sp-s); overflow: hidden; } .shape-title { - font-size: deprecated.$fs-14; + // TODO: No exact DS typography match found. + // Closest match: body-medium. + font-size: px2rem(14); background: var(--color-background-quaternary); color: var(--color-foreground-primary); - padding: deprecated.$s-8; - border-radius: deprecated.$s-8; + padding: var(--sp-s); + border-radius: $br-8; display: flex; - gap: deprecated.$s-4; + align-items: center; + gap: var(--sp-xs); } .shape-name { @@ -41,7 +49,7 @@ .attrs-container-attr { display: grid; grid-template-columns: 25% auto; - padding: deprecated.$s-4 0; + padding: var(--sp-xs) 0; &.vertical-layout { grid-template-columns: auto; @@ -51,8 +59,8 @@ .shape-attrs { overflow: auto; - height: calc(100% - 8px); - padding-bottom: 8px; + block-size: calc(100% - var(--sp-s)); + padding-block-end: var(--sp-s); } .shape-link { @@ -63,15 +71,15 @@ .shape-list { display: flex; flex-direction: column; - gap: deprecated.$s-4; + gap: var(--sp-xs); } .point-list { display: flex; - gap: deprecated.$s-8; + gap: var(--sp-s); } .cell-container { display: grid; - grid-template-columns: 100px 1fr; + grid-template-columns: px2rem(100) 1fr; } From 5dbf0731e5b94b125f96499a37a28b2d02ebe372 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 13:11:04 +0200 Subject: [PATCH 125/325] :recycle: Use DS buttons and icons in the plugins dialogs (#12470) * :recycle: Use DS buttons and icons in the plugins dialogs The plugins manager and the permissions, permissions update and try-out dialogs used raw buttons, input elements styled as buttons and deprecated icons. Replace them with the DS components: - The four close buttons become ghost icon buttons labeled "Close", shown in their DS tooltip. The class that places them in the corner goes on the tooltip wrapper, so the tooltip anchors to the button. - Install, and the cancel and accept buttons of the three dialogs, become DS primary and secondary buttons. - The empty list puzzle and the external link icons use the DS icon. The permission icons (oauth-*) stay: they are brand art. Add a visual test with fixtures for a team with the plugins feature and a profile with one installed plugin. It shows the empty manager, the manager with a plugin and the permissions dialog. AI-assisted-by: claude-opus-5-5 * :recycle: Move plugins dialog styles off the deprecated styles Remove the styles the DS buttons and icons no longer need (close-icon, primary-button, cancel-button, search-icon and the svg rules of the migrated icons) and move the rest of plugins.scss to DS tokens, DS typographies and logical properties. - The close button wrapper keeps only its position; the deprecated tertiary button style was landing on the tooltip wrapper. - The full-width cancel and accept buttons center their label again: the DS button does not center it when it grows. - The external link icon gets a class instead of an svg rule. - The deprecated body-small used line-height 1.4 and the DS one 1.3, so the permissions dialog is a few pixels shorter. The refactor import stays for the modal placeholders until the modals move to the DS modal. AI-assisted-by: claude-opus-5-5 --- .../get-profile-with-plugins.json | 44 ++++ .../data/workspace/get-team-plugins.json | 24 +++ .../ui/visual-specs/workspace.spec.js | 51 +++++ .../src/app/main/ui/workspace/plugins.cljs | 97 +++++---- .../src/app/main/ui/workspace/plugins.scss | 203 ++++++++---------- 5 files changed, 264 insertions(+), 155 deletions(-) create mode 100644 frontend/playwright/data/logged-in-user/get-profile-with-plugins.json create mode 100644 frontend/playwright/data/workspace/get-team-plugins.json diff --git a/frontend/playwright/data/logged-in-user/get-profile-with-plugins.json b/frontend/playwright/data/logged-in-user/get-profile-with-plugins.json new file mode 100644 index 0000000000..38dbba2b31 --- /dev/null +++ b/frontend/playwright/data/logged-in-user/get-profile-with-plugins.json @@ -0,0 +1,44 @@ +{ + "~:email": "foo@example.com", + "~:is-demo": false, + "~:auth-backend": "penpot", + "~:fullname": "Princesa Leia", + "~:modified-at": "~m1713533116365", + "~:is-active": true, + "~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:is-muted": false, + "~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116365", + "~:is-blocked": false, + "~:props": { + "~:nudge": { + "~:big": 10, + "~:small": 1 + }, + "~:v2-info-shown": true, + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false, + "~:plugins": { + "~:ids": [ + "0a5b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d" + ], + "~:data": { + "0a5b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d": { + "~:plugin-id": "0a5b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", + "~:version": 2, + "~:name": "Lorem Ipsum generator", + "~:description": "Fill text layers with placeholder text.", + "~:host": "https://plugin.example.com/", + "~:code": "plugin.js", + "~:permissions": { + "~#set": [ + "content:read", + "content:write" + ] + } + } + } + } + } +} diff --git a/frontend/playwright/data/workspace/get-team-plugins.json b/frontend/playwright/data/workspace/get-team-plugins.json new file mode 100644 index 0000000000..83d7cf47ce --- /dev/null +++ b/frontend/playwright/data/workspace/get-team-plugins.json @@ -0,0 +1,24 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "plugins/runtime" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116375", + "~:is-default": true +} diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 4ff50815a0..99a79bef99 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -509,6 +509,57 @@ test.describe("Palette", () => { }); }); +test.describe("Plugins", () => { + const setupPlugins = async (workspace, profile) => { + await workspace.setupEmptyFile(); + await workspace.mockRPC("get-team?id=*", "workspace/get-team-plugins.json"); + if (profile) { + await workspace.mockRPC("get-profile", profile); + } + await workspace.goToWorkspace(); + await workspace.page.keyboard.press("Control+Alt+P"); + }; + + test("Shows the plugins manager without plugins", async ({ page }) => { + const workspace = new WorkspacePage(page); + await setupPlugins(workspace); + + await expect(page.getByText("No plugins installed yet")).toBeVisible(); + await expect(page.getByRole("button", { name: "Close" })).toBeVisible(); + await expect(page).toHaveScreenshot(); + }); + + test("Shows the plugins manager and the permissions dialog", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await page.route("https://plugin.example.com/manifest.json", (route) => + route.fulfill({ + json: { + name: "Contrast checker", + description: "Check the contrast of the selected layers.", + code: "plugin.js", + permissions: ["content:write", "user:read", "library:write"], + }, + }), + ); + await setupPlugins( + workspace, + "logged-in-user/get-profile-with-plugins.json", + ); + + await expect(page.getByText("Lorem Ipsum generator")).toBeVisible(); + await expect(page).toHaveScreenshot(); + + await page + .getByRole("textbox") + .fill("https://plugin.example.com/manifest.json"); + await page.getByRole("button", { name: "Install" }).click(); + await expect(page.getByText("CONTRAST CHECKER")).toBeVisible(); + await expect(page).toHaveScreenshot(); + }); +}); + test.describe("Nudge modal", () => { test("Shows the nudge amount modal", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 478566b45a..cd73c45664 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -20,7 +20,7 @@ [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.icons :as deprecated-icon] [app.plugins.register :as preg] [app.util.avatars :as avatars] @@ -31,9 +31,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - (defn- normalize-plugin-url "Automatically appens manifest.json if the plugin-uri comes without it." [plugin-url] @@ -211,7 +208,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-management)} - [:button {:class (stl/css :close-btn) :on-click modal/hide!} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}] [:div {:class (stl/css :modal-title)} (tr "workspace.plugins.title")] [:div {:class (stl/css :modal-content)} @@ -222,9 +223,9 @@ :placeholder (tr "workspace.plugins.search-placeholder") :class (stl/css-case :input-error error?)}] - [:button {:class (stl/css :primary-button) - :disabled @fetching-manifest? - :on-click on-install} + [:> button* {:variant "primary" + :disabled @fetching-manifest? + :on-click on-install} (tr "workspace.plugins.install")]] (when error-url? @@ -245,13 +246,17 @@ (if (empty? plugins-state) [:div {:class (stl/css :plugins-empty)} - [:div {:class (stl/css :plugins-empty-logo)} deprecated-icon/puzzle] + [:div {:class (stl/css :plugins-empty-logo)} + [:> icon* {:icon-id i/puzzle :size "m"}]] [:div {:class (stl/css :plugins-empty-text)} (tr "workspace.plugins.empty-plugins")] [:a {:class (stl/css :plugins-link) :href cfg/plugins-list-uri :target "_blank" :on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"}))} - (tr "workspace.plugins.plugin-list-link") deprecated-icon/external-link]] + (tr "workspace.plugins.plugin-list-link") + [:> icon* {:icon-id i/external-link + :size "s" + :class (stl/css :plugins-link-icon)}]]] [:* [:> title-bar* {:collapsable false @@ -411,7 +416,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-permissions)} - [:button {:class (stl/css :close-btn) :on-click on-close-dialog} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close-dialog}] [:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions.title" (str/upper (:name plugin)))] [:div {:class (stl/css :modal-content)} @@ -423,17 +432,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button :button-expand) - :type "button" - :value (tr "ds.confirm-cancel") - :on-click on-close-dialog}] + [:> button* {:variant "secondary" + :class (stl/css :button-expand) + :on-click on-close-dialog} + (tr "ds.confirm-cancel")] - [:input - {:class (stl/css :primary-button :button-expand) - :type "button" - :value (tr "ds.confirm-allow") - :on-click on-accept-dialog}]]]]])) + [:> button* {:variant "primary" + :class (stl/css :button-expand) + :on-click on-accept-dialog} + (tr "ds.confirm-allow")]]]]])) (mf/defc plugins-permissions-updated-dialog @@ -475,7 +482,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-permissions)} - [:button {:class (stl/css :close-btn) :on-click on-close-dialog} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close-dialog}] [:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions-update.title" (str/upper (:name plugin)))] @@ -486,17 +497,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button :button-expand) - :type "button" - :value (tr "ds.confirm-cancel") - :on-click on-close-dialog}] + [:> button* {:variant "secondary" + :class (stl/css :button-expand) + :on-click on-close-dialog} + (tr "ds.confirm-cancel")] - [:input - {:class (stl/css :primary-button :button-expand) - :type "button" - :value (tr "ds.confirm-allow") - :on-click on-accept-dialog}]]]]])) + [:> button* {:variant "primary" + :class (stl/css :button-expand) + :on-click on-accept-dialog} + (tr "ds.confirm-allow")]]]]])) (mf/defc plugins-try-out-dialog @@ -530,7 +539,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :plugin-try-out)} - [:button {:class (stl/css :close-btn) :on-click on-close-dialog} close-icon] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close-dialog}] [:div {:class (stl/css :modal-title)} [:div {:class (stl/css :plugin-icon)} [:img {:src (if (some? icon) @@ -544,14 +557,12 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button :button-expand) - :type "button" - :value (tr "workspace.plugins.try-out.cancel") - :on-click on-close-dialog}] + [:> button* {:variant "secondary" + :class (stl/css :button-expand) + :on-click on-close-dialog} + (tr "workspace.plugins.try-out.cancel")] - [:input - {:class (stl/css :primary-button :button-expand) - :type "button" - :value (tr "workspace.plugins.try-out.try") - :on-click on-accept-dialog}]]]]])) + [:> button* {:variant "primary" + :class (stl/css :button-expand) + :on-click on-accept-dialog} + (tr "workspace.plugins.try-out.try")]]]]])) diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss index c67d5ea498..40f770c57c 100644 --- a/frontend/src/app/main/ui/workspace/plugins.scss +++ b/frontend/src/app/main/ui/workspace/plugins.scss @@ -4,8 +4,15 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; @use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; + +// Only for the modal placeholders (%modal-overlay-base, +// %modal-container-base), until the modals move to the DS modal. +@use "refactor/common-refactor.scss" as deprecated; .modal-overlay { @extend %modal-overlay-base; @@ -17,49 +24,47 @@ display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr); - max-height: initial; + max-block-size: initial; &.plugin-permissions { - width: deprecated.$s-412; - max-width: deprecated.$s-412; + inline-size: px2rem(412); + max-inline-size: px2rem(412); } &.plugin-management { - width: deprecated.$s-472; - max-width: deprecated.$s-472; + inline-size: px2rem(472); + max-inline-size: px2rem(472); } &.plugin-try-out { - width: deprecated.$s-452; - max-width: deprecated.$s-452; + inline-size: px2rem(452); + max-inline-size: px2rem(452); } + // The separator of the plugins manager has no class. hr { border-color: var(--color-background-tertiary); } } .modal-footer { - margin-top: 2rem; + margin-block-start: var(--sp-xxxl); } +// Goes on the tooltip wrapper of the close icon button. .close-btn { - @extend %modal-close-btn-base; -} - -.close-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-title { - @include deprecated.headline-medium-typography; + @include use-typography("headline-medium"); - margin-block-end: deprecated.$s-32; - color: var(--modal-title-foreground-color); + margin-block-end: var(--sp-xxxl); + color: var(--color-foreground-primary); display: flex; - gap: deprecated.$s-12; + gap: var(--sp-m); } .modal-content { @@ -67,113 +72,92 @@ flex-direction: column; .plugin-permissions & { - gap: deprecated.$s-20; + gap: var(--sp-xl); } .plugin-management & { - height: deprecated.$s-380; - max-height: deprecated.$s-380; + block-size: px2rem(380); + max-block-size: px2rem(380); } } .modal-message { - font-size: deprecated.$fs-14; + // TODO: No exact DS typography match found. + // Closest match: body-medium. + font-size: px2rem(14); color: var(--color-foreground-secondary); } .modal-paragraph { - font-size: deprecated.$fs-14; + // TODO: No exact DS typography match found. + // Closest match: body-medium. + font-size: px2rem(14); color: var(--color-foreground-primary); } -.primary-button { - @extend %button-primary; - @include deprecated.headline-small-typography; - - padding: deprecated.$s-0 deprecated.$s-16; -} - +// The DS button does not center its label when it grows. .button-expand { - width: 100%; + inline-size: 100%; margin: 0; -} - -.cancel-button { - @extend %button-secondary; - @include deprecated.headline-small-typography; - - padding: deprecated.$s-0 deprecated.$s-16; -} - -.search-icon { - @include deprecated.flex-center; - - width: deprecated.$s-20; - padding: 0 0 0 deprecated.$s-8; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } + justify-content: center; } .top-bar { display: flex; - gap: deprecated.$s-8; + gap: var(--sp-s); } .open-button { display: flex; justify-content: center; align-items: center; - width: deprecated.$s-68; - min-width: deprecated.$s-68; - height: deprecated.$s-32; + inline-size: px2rem(68); + min-inline-size: px2rem(68); + block-size: $sz-32; text-transform: uppercase; } .plugins-list { - padding-top: deprecated.$s-20; + padding-block-start: var(--sp-xl); overflow: hidden auto; flex: 1; display: flex; flex-direction: column; - gap: deprecated.$s-12; + gap: var(--sp-m); } .plugins-list-element { display: flex; - gap: deprecated.$s-12; + gap: var(--sp-m); } .plugin-icon { - min-width: deprecated.$s-32; - min-height: deprecated.$s-32; - width: deprecated.$s-32; - height: deprecated.$s-32; - background: var(--button-secondary-background-color-rest); - padding: deprecated.$s-2; - border-radius: deprecated.$s-4; + min-inline-size: $sz-32; + min-block-size: $sz-32; + inline-size: $sz-32; + block-size: $sz-32; + background: var(--color-background-tertiary); + padding: var(--sp-xxs); + border-radius: $br-4; } .plugin-description { display: flex; flex-direction: column; - gap: deprecated.$s-8; - width: 100%; - min-width: 0; + gap: var(--sp-s); + inline-size: 100%; + min-inline-size: 0; } .plugin-title { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); @include text-ellipsis; color: var(--color-foreground-primary); } .plugin-summary { - @include deprecated.body-small-typography; + @include use-typography("body-small"); @include two-line-text-ellipsis; overflow-wrap: anywhere; @@ -184,116 +168,111 @@ display: flex; flex-direction: column; align-items: center; - gap: deprecated.$s-20; - height: 100%; + gap: var(--sp-xl); + block-size: 100%; justify-content: center; - padding: deprecated.$s-36 0; + padding: px2rem(36) 0; } .plugins-empty-logo { - width: deprecated.$s-44; - height: deprecated.$s-44; - border-radius: 50%; + inline-size: px2rem(44); + block-size: px2rem(44); + border-radius: $br-circle; display: flex; justify-content: center; align-items: center; background: var(--color-background-tertiary); - - svg { - width: deprecated.$s-16; - height: deprecated.$s-16; - stroke: var(--color-foreground-secondary); - fill: none; - } + color: var(--color-foreground-secondary); } .plugins-empty-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); color: var(--color-foreground-primary); } div.input-error { - border: deprecated.$s-1 solid var(--input-border-color-error); + border: $b-1 solid var(--status-color-error-500); } .info { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-top: deprecated.$s-4; + margin-block-start: var(--sp-xs); + color: var(--info-foreground-color); &.error { - color: var(--input-border-color-error); + --info-foreground-color: var(--status-color-error-500); } &.success { - color: var(--input-border-color-success); + --info-foreground-color: var(--color-accent-primary); } } .plugins-link { + // TODO: No exact DS typography match found. + // Closest match: body-small. + font-size: px2rem(12); color: var(--color-accent-primary); - font-size: deprecated.$fs-12; display: inline-flex; align-items: center; - gap: deprecated.$s-4; + gap: var(--sp-xs); +} - svg { - margin-top: calc(-1 * deprecated.$s-2); - width: deprecated.$s-12; - height: deprecated.$s-12; - stroke: var(--color-accent-primary); - fill: none; - } +.plugins-link-icon { + margin-block-start: calc(-1 * var(--sp-xxs)); } .permissions-list { display: flex; flex-direction: column; - gap: deprecated.$s-24; + gap: var(--sp-xxl); } .permissions-list-entry { display: grid; - grid-template-columns: 24px 1fr; - gap: deprecated.$s-16; + grid-template-columns: $sz-24 1fr; + gap: var(--sp-l); align-items: center; + // The permission icons are legacy brand art with no class. svg { - width: deprecated.$s-24; - height: deprecated.$s-24; + inline-size: $sz-24; + block-size: $sz-24; stroke: var(--color-accent-primary); fill: none; } } .permissions-list-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); margin: 0; color: var(--color-foreground-secondary); } .permissions-disclaimer { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - padding: deprecated.$s-16; + padding: var(--sp-l); background: var(--color-background-quaternary); color: var(--color-foreground-primary); - border-radius: deprecated.$br-4; + border-radius: $br-4; } .action-buttons { display: flex; - gap: deprecated.$s-12; + gap: var(--sp-m); } .discover { - @include deprecated.body-small-typography; + @include use-typography("body-small"); color: var(--color-foreground-secondary); - margin-top: deprecated.$s-24; + margin-block-start: var(--sp-xxl); + // The link comes from a translated HTML string, with no class. a { color: var(--color-accent-primary); } From 5259b4b2d5114173a7025e2fef8731a4344508da Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:10:34 +0200 Subject: [PATCH 126/325] :recycle: Use DS buttons in the workspace right header (#12454) * :recycle: Use DS buttons in the workspace right header The comments and history buttons were raw buttons with deprecated icons; they become DS ghost icon buttons that show their state through aria-pressed, with the unread dot as a child of the comments button. The view mode link had a click handler and no href; it becomes a DS icon button too, so it gets a button role and keyboard activation. Their tooltips open below and to the left, at the top right corner of the screen. The zoom menu's Reset is a DS ghost button. The share link stays raw with its deprecated icon until the DS has a share icon; it now uses its own class instead of the view mode one. Add a visual test that captures the header at rest, with comments active, and with the zoom menu open. AI-assisted-by: claude-opus-5-5 * :recycle: Move workspace right header styles off the deprecated styles The comments, history and view mode buttons are DS icon buttons, so their placeholders go away; the comments class only holds the unread dot. The zoom widget color goes through a local variable, and spacing, sizes, borders and the color aliases move to DS tokens and logical properties. Delete the save status rules: the status widget moved to the left header in #5773 and nothing here uses them anymore. refactor/ stays only for the shared menu placeholders and for the tertiary button and icon placeholders of the share link. AI-assisted-by: claude-opus-5-5 * :bug: Give the action icon button a color while pressed or active The action variant of the DS icon button set the active background but not the active foreground, so it kept the base value, initial. A custom property set to initial is invalid at computed time, so the icon took the color of its container while the button was pressed (:active) or toggled on (aria-pressed): yellow in the workspace header, for example. Use the accent color, as the ghost and secondary variants do. AI-assisted-by: claude-opus-5-5 * :lipstick: Use the action variant for the right header icon buttons The comments, history and view mode buttons had no background at rest, on hover or while active. The DS action variant keeps that: no background at rest and on hover, accent icon on hover. It is 24px instead of the ghost 32px, and shows a small background while comments or history is on. The unread dot moves to 4px from the button corner, so it stays over the top right corner of the icon in the smaller button. AI-assisted-by: claude-opus-5-5 * :lipstick: Drop the background of a pressed action icon button A toggled-on action icon button (aria-pressed) showed the quaternary background next to the accent icon. Keep only the accent icon, as the workspace header buttons looked before the DS migration. The background stays for the click feedback (:active). Only the right header comments and history buttons use the action variant with aria-pressed today. AI-assisted-by: claude-opus-5-5 * :lipstick: Widen the gap between the right header actions The action icon buttons are 24px wide, smaller than the old ones, so the comments, history and view mode buttons sat closer together than before the DS migration. Raise the header gap from --sp-s to --sp-m to keep the old spacing between the icons without resizing the buttons. The separator absorbs the extra space, so the header width stays the same. AI-assisted-by: claude-opus-5-5 * :recycle: Make the right header zoom widget a button The zoom percentage of the right header opened its menu from a div with a click handler: no button role and no keyboard access. Make it a button with aria-expanded, keeping its classes, title and handler; its visible percentage is its accessible name. Reset the browser padding of the button so the percentage stays in place. AI-assisted-by: claude-opus-5-5 * :sparkles: Toggle the zoom dropdown from the zoom widget Clicking the zoom widget while its dropdown was open left it open: the handler always set the open state, and it stops the click before the dropdown sees it. The widget now toggles the dropdown, as its aria-expanded already suggests. Closing from outside or with Escape works as before. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 34 +++ .../app/main/ui/ds/buttons/icon_button.scss | 7 + .../app/main/ui/workspace/right_header.cljs | 59 ++--- .../app/main/ui/workspace/right_header.scss | 226 +++++------------- 4 files changed, 135 insertions(+), 191 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 99a79bef99..2d0752bbad 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -481,6 +481,40 @@ test.describe("Left header", () => { }); }); +test.describe("Right header", () => { + test("Shows the header actions and the zoom menu", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + const header = page.locator('[class*="workspace-header-right"]'); + await expect(header).toBeVisible(); + await page.mouse.move(640, 300); + // The buttons are a small part of the header: compare every pixel. + await expect(header).toHaveScreenshot("right-header.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await header.getByRole("button", { name: /Comments/ }).click(); + await page.mouse.move(640, 300); + await expect(header).toHaveScreenshot("right-header-comments.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + await page.keyboard.press("Escape"); + + await header.getByTitle("Zoom").click(); + await expect(page.getByText("Reset")).toBeVisible(); + await page.mouse.move(640, 300); + await expect(page).toHaveScreenshot("right-header-zoom.png", { + clip: { x: 900, y: 0, width: 380, height: 260 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Palette", () => { test("Shows the bottom palette expanded and collapsed", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss index 291c352081..85cda9703e 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss @@ -41,6 +41,7 @@ --button-hover-bg-color: transparent; --button-hover-fg-color: var(--color-accent-primary); --button-active-bg-color: var(--color-background-quaternary); + --button-active-fg-color: var(--color-accent-primary); --button-disabled-bg-color: transparent; --button-disabled-fg-color: var(--color-accent-primary-muted); --button-focus-bg-color: transparent; @@ -49,6 +50,12 @@ --button-focus-outer-ring-color: var(--color-accent-primary); --button-width: #{$sz-24}; --button-height: #{$sz-24}; + + // A toggled-on action button shows only the accent icon; the background + // stays for the click feedback (:active). + &[aria-pressed="true"] { + --button-active-bg-color: transparent; + } } .flyout-indicator { diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 28233e3204..0b2f9a9b0e 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -20,6 +20,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] [app.main.ui.dashboard.team] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.exports.assets :refer [progress-widget]] @@ -44,11 +45,11 @@ custom-shortcuts (mf/deref refs/custom-shortcuts) get-tt #(sc/get-effective-tooltip % custom-shortcuts) - open-dropdown + toggle-dropdown (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! open* true))) + (swap! open* not))) close-dropdown (mf/use-fn @@ -73,10 +74,12 @@ zoom (fmt/format-percent zoom {:precision 0})] [:* - [:div {:on-click open-dropdown - :class (stl/css-case :zoom-widget true - :selected open?) - :title (tr "workspace.header.zoom")} + [:button {:type "button" + :on-click toggle-dropdown + :class (stl/css-case :zoom-widget true + :selected open?) + :title (tr "workspace.header.zoom") + :aria-expanded open?} [:span {:class (stl/css :label)} zoom]] [:& dropdown {:show open? :on-close close-dropdown} [:ul {:class (stl/css :dropdown)} @@ -91,8 +94,8 @@ :aria-label (tr "shortcuts.increase-zoom") :on-click on-increase :icon i/add}]] - [:button {:class (stl/css :reset-btn) - :on-click on-zoom-reset} + [:> button* {:variant "ghost" + :on-click on-zoom-reset} (tr "workspace.header.reset-zoom")]] [:li {:class (stl/css :zoom-option) :on-click on-zoom-fit} @@ -215,35 +218,35 @@ :on-zoom-selected on-zoom-selected}]] [:div {:class (stl/css :comments-section)} - [:button {:title (tr "workspace.toolbar.comments" (get-tt :add-comment)) - :aria-label (tr "workspace.toolbar.comments" (get-tt :add-comment)) - :class (stl/css-case :comments-btn true - :selected (= selected-drawtool :comments)) - :on-click toggle-comments - :data-tool "comments" - :style {:position "relative"}} - deprecated-icon/comments + [:> icon-button* {:variant "action" + :class (stl/css :comments-btn) + :tooltip-placement "bottom-left" + :aria-pressed (= selected-drawtool :comments) + :aria-label (tr "workspace.toolbar.comments" (get-tt :add-comment)) + :icon i/comments + :on-click toggle-comments + :data-tool "comments"} (when ^boolean has-unread-comments? [:div {:class (stl/css :unread)}])]] (when-not ^boolean read-only? [:div {:class (stl/css :history-section)} - [:button - {:title (tr "workspace.sidebar.history") - :aria-label (tr "workspace.sidebar.history") - :class (stl/css-case :selected (contains? layout :document-history) - :history-button true) - :on-click toggle-history} - deprecated-icon/history]]) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-pressed (contains? layout :document-history) + :aria-label (tr "workspace.sidebar.history") + :icon i/history + :on-click toggle-history}]]) (when display-share-button? - [:a {:class (stl/css :viewer-btn) + [:a {:class (stl/css :share-btn) :title (tr "workspace.header.share") :on-click open-share-dialog} deprecated-icon/share]) - [:a {:class (stl/css :viewer-btn) - :title (tr "workspace.header.viewer" (get-tt :open-viewer)) - :on-click nav-to-viewer} - deprecated-icon/play]])) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-label (tr "workspace.header.viewer" (get-tt :open-viewer)) + :icon i/play + :on-click nav-to-viewer}]])) diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 2b338659aa..040b270490 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -4,23 +4,30 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// Only for the shared menu placeholders, and the button placeholders of +// the share link until the DS has a share icon. @use "refactor/common-refactor.scss" as deprecated; .workspace-header-right { display: flex; justify-content: space-between; align-items: center; - min-width: deprecated.$s-256; - padding: deprecated.$s-8; - gap: deprecated.$s-8; - background-color: var(--panel-background-color); + min-inline-size: px2rem(256); + padding: var(--sp-s); + gap: var(--sp-m); + background-color: var(--color-background-primary); } .users-section { position: relative; - min-width: deprecated.$s-32; - max-width: deprecated.$s-72; - padding: deprecated.$s-4 deprecated.$s-6; + min-inline-size: $sz-32; + max-inline-size: px2rem(72); + padding: var(--sp-xs) px2rem(6); } .separator { @@ -28,49 +35,46 @@ } .zoom-widget { - @include deprecated.button-style; + --zoom-widget-foreground-color: var(--color-foreground-secondary); display: flex; align-items: center; justify-content: center; - height: deprecated.$s-28; - max-width: deprecated.$s-48; - width: deprecated.$s-48; - border-radius: deprecated.$br-8; + block-size: $sz-28; + max-inline-size: $sz-48; + inline-size: $sz-48; + padding: 0; + border: none; + border-radius: $br-8; + background: none; + cursor: pointer; .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - height: 100%; - padding: deprecated.$s-8 0; - color: var(--button-tertiary-foreground-color-rest); - } - - &:hover { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + block-size: 100%; + padding: var(--sp-s) 0; + color: var(--zoom-widget-foreground-color); } + &:hover, &.selected { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + --zoom-widget-foreground-color: var(--color-foreground-primary); } } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); } .basic-zoom-bar { display: flex; justify-content: space-between; - padding: deprecated.$s-6; + padding: px2rem(6); cursor: auto; } @@ -79,21 +83,14 @@ } .zoom-text { - @include deprecated.flex-center; - - height: 100%; - min-width: deprecated.$s-48; + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + min-inline-size: $sz-48; padding: 0; - margin: 0 deprecated.$s-2; - color: var(--modal-title-foreground-color); -} - -.reset-btn { - @extend %button-tertiary; - - color: var(--button-tertiary-foreground-color-hover); - height: deprecated.$s-28; - border-radius: deprecated.$br-8; + margin: 0 px2rem(2); + color: var(--color-foreground-primary); } .zoom-option { @@ -108,137 +105,38 @@ } &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); .shortcuts { .shortcut-key { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); } } } } +// Holds the unread dot. .comments-btn { + position: relative; +} + +// Raw link until the DS has a share icon. +.share-btn { @extend %button-tertiary; - border-radius: deprecated.$br-8; + border-radius: $br-8; margin: 0; - height: deprecated.$s-28; - width: deprecated.$s-28; + inline-size: $sz-28; + block-size: $sz-28; border: none; + // The legacy share icon has no class. svg { @extend %button-icon; - stroke: var(--icon-foreground); - height: deprecated.$s-16; - width: deprecated.$s-16; - } - - &:hover { - background-color: transparent; - border: none; - } - - &.selected { - background-color: var(--button-tertiary-background-color-selected); - - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } -} - -.history-button { - @extend %button-tertiary; - - border-radius: deprecated.$br-8; - margin: 0; - height: deprecated.$s-28; - width: deprecated.$s-28; - border: none; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - height: deprecated.$s-16; - width: deprecated.$s-16; - } - - &:hover { - background-color: transparent; - border: none; - } - - &.selected { - background-color: var(--button-tertiary-background-color-selected); - - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } -} - -.persistence-status-widget { - @include deprecated.flex-center; - - width: deprecated.$s-28; - height: deprecated.$s-28; -} - -.status-icon { - @include deprecated.flex-center; - - width: deprecated.$s-24; - height: deprecated.$s-24; - margin: 0; - border-radius: deprecated.$br-circle; - - svg { - @extend %button-icon; - - stroke: var(--status-widget-icon-foreground-color); - } -} - -.pending-status { - background-color: var(--status-widget-background-color-warning); -} - -.saving-status { - background-color: var(--status-widget-background-color-pending); - - svg { - animation: spin-animation 1s infinite; - animation-timing-function: linear; - } -} - -.saved-status { - background-color: var(--status-widget-background-color-success); -} - -.error-status { - background-color: var(--status-widget-background-color-error); -} - -.share-btn, -.viewer-btn { - @extend %button-tertiary; - - border-radius: deprecated.$br-8; - margin: 0; - width: deprecated.$s-28; - height: deprecated.$s-28; - border: none; - - svg { - @extend %button-icon; - - height: deprecated.$s-16; - width: deprecated.$s-16; - stroke: var(--icon-foreground); + block-size: $sz-16; + inline-size: $sz-16; + stroke: var(--color-foreground-secondary); } &:hover { @@ -249,11 +147,13 @@ .unread { position: absolute; - width: 8px; - height: 8px; - border: 2px solid var(--color-background-tertiary); - border-radius: 50%; + inline-size: px2rem(8); + block-size: px2rem(8); + border: $b-2 solid var(--color-background-tertiary); + border-radius: $br-circle; background: var(--color-foreground-error); - top: 6px; - right: 6px; + + // Over the top right corner of the icon, 4px from the 24px button edge. + inset-block-start: var(--sp-xs); + inset-inline-end: var(--sp-xs); } From ce84fce0dcec843281f78d79b303f32d21715fe5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:10:53 +0200 Subject: [PATCH 127/325] :lipstick: Remove the border from the boolean operation buttons (#12510) The boolean operation buttons at the top of the Design tab showed the 2px border of the legacy radio buttons. The design team is reworking this section; for now only the border goes away, in normal and selected state, by making the border color variables transparent there. AI-assisted-by: claude-opus-5-5 --- .../src/app/main/ui/workspace/sidebar/options/menus/bool.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index 2a03d0fd51..afd0ca9dac 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -23,6 +23,9 @@ } .boolean-radio-btn { + --radio-btn-border-color: transparent; + --radio-btn-border-color-selected: transparent; + background-color: transparent; gap: var(--sp-xs); } From 1d54d170cfb822e076c4b9d96c5271ca26f65abe Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:11:15 +0200 Subject: [PATCH 128/325] :recycle: Move layers panel styles off the deprecated button styles (#12478) The layers panel already uses DS icons and buttons; its two raw buttons (the layer filter pill and the focus mode title) stay raw, since they are custom widgets. Their styles still used the deprecated %button-tag placeholder and color aliases. - Inline %button-tag in the filter pill: border, cursor, centering, and its focus and pressed states through local variables. Its hover rule set the icons to the color they already had, so it goes away. - Resolve the icon, title, pill and menu color aliases to DS tokens. The filter menu item texts are the same color in every state, so only the background changes, through a local variable. The refactor import stays for the shared %menu-dropdown placeholder. Add a visual test of the focus mode title, a filter pill and the filter menu; the panel looks the same as before, pixel for pixel. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 45 ++++++++ .../app/main/ui/workspace/sidebar/layers.scss | 101 ++++++++---------- 2 files changed, 91 insertions(+), 55 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 2d0752bbad..f324163b4a 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -418,6 +418,51 @@ test.describe("Debug shape panel", () => { }); }); +test.describe("Layers panel", () => { + test("Shows the focus mode title and the layer filters", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickLeafLayer("Board"); + await page.keyboard.press("f"); + await expect(workspace.sidebar.getByText("Focus mode")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(workspace.sidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.keyboard.press("f"); + await expect(workspace.sidebar.getByText("Focus mode")).toBeHidden(); + await workspace.sidebar.getByRole("button", { name: "Search" }).click(); + await workspace.sidebar.getByRole("button", { name: "Filter" }).click(); + await workspace.sidebar.getByText("Boards", { exact: true }).click(); + await page.mouse.move(640, 600); + // The "Boards" filter pill. The clip leaves out the pages divider and + // the search bar, whose transitions may still be running. + const filtersClip = { x: 0, y: 340, width: 318, height: 380 }; + await expect(page).toHaveScreenshot({ + clip: filtersClip, + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.sidebar.getByRole("button", { name: "Filter" }).click(); + await page.mouse.move(640, 600); + // The filter menu open, with its option ticked. + await expect(page).toHaveScreenshot({ + clip: filtersClip, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Guides", () => { test("Shows the advanced options of a square guide", async ({ page }) => { // A taller window keeps the whole Design tab visible: a scrolled diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index 4ff85ab137..e702e9c9fa 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -4,6 +4,7 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared %menu-dropdown placeholder of the filters menu. @use "refactor/common-refactor.scss" as deprecated; @use "ds/borders.scss" as *; @use "ds/mixins.scss" as *; @@ -63,7 +64,7 @@ justify-content: center; block-size: var(--sp-xxxl); padding-inline-start: var(--sp-s); - color: var(--icon-foreground); + color: var(--color-foreground-secondary); } .focus-name { @@ -71,7 +72,7 @@ @include text-ellipsis; padding-inline-end: var(--sp-xs); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .focus-mode-tag-wrapper { @@ -91,18 +92,36 @@ } .layer-filter { - @extend %button-tag; + --layer-filter-background-color: var(--color-background-tertiary); + --layer-filter-border: none; + display: flex; + justify-content: center; + align-items: center; gap: px2rem(6); block-size: var(--sp-xxl); margin: var(--sp-xxs) 0; + border: var(--layer-filter-border); border-radius: $br-6; - background-color: var(--pill-background-color); + background-color: var(--layer-filter-background-color); + cursor: pointer; + + &:focus-visible { + --layer-filter-background-color: var(--color-background-secondary); + --layer-filter-border: #{$b-1} solid var(--color-accent-primary); + + outline: none; + } + + &:active { + --layer-filter-background-color: transparent; + --layer-filter-border: none; + } } .layer-filter-icon, .layer-filter-close { - color: var(--pill-foreground-color); + color: var(--color-foreground-primary); } .layer-filter-name { @@ -111,7 +130,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--pill-foreground-color); + color: var(--color-foreground-primary); } .layers { @@ -213,6 +232,8 @@ inline-size: $sz-192; .filter-menu-item { + --filter-menu-item-background-color: transparent; + @include t.use-typography("body-small"); display: flex; @@ -221,60 +242,30 @@ inline-size: 100%; padding: px2rem(6); border-radius: $br-8; - - .filter-menu-item-name-wrapper { - display: flex; - align-items: center; - gap: var(--sp-s); - - .filter-menu-item-icon { - color: var(--menu-foreground-color); - } - - .filter-menu-item-name { - padding-block-start: var(--sp-xxs); - color: var(--menu-foreground-color); - } - } - - .filter-menu-item-tick { - color: var(--menu-foreground-color); - } + background-color: var(--filter-menu-item-background-color); &.selected { - background-color: var(--menu-background-color-selected); - - .filter-menu-item-name-wrapper { - .filter-menu-item-icon { - color: var(--menu-foreground-color); - } - - .filter-menu-item-name { - color: var(--menu-foreground-color); - } - } - - .filter-menu-item-tick { - color: var(--menu-foreground-color); - } + --filter-menu-item-background-color: var(--color-background-tertiary); } &:hover { - background-color: var(--menu-background-color-hover); - - .filter-menu-item-name-wrapper { - .filter-menu-item-icon { - color: var(--menu-foreground-color-hover); - } - - .filter-menu-item-name { - color: var(--menu-foreground-color-hover); - } - } - - .filter-menu-item-tick { - color: var(--menu-foreground-color-hover); - } + --filter-menu-item-background-color: var(--color-background-quaternary); } } + + .filter-menu-item-name-wrapper { + display: flex; + align-items: center; + gap: var(--sp-s); + } + + .filter-menu-item-icon, + .filter-menu-item-name, + .filter-menu-item-tick { + color: var(--color-foreground-primary); + } + + .filter-menu-item-name { + padding-block-start: var(--sp-xxs); + } } From 95bc48a08a7ccaafec5c0ab5eabab9424d20087a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 21 Oct 2026 12:09:08 +0000 Subject: [PATCH 129/325] :bug: Fix Admin Console URLs escaped twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Admin Console location built its proxy_pass target from a capture of $request_uri, which nginx escapes again when the path has escaped characters: %XX became %25XX and the query string ended up in the path, so Admin Console links returned 404. Strip the prefix with a rewrite and let proxy_pass forward the rewritten URI instead. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .../nginx-admin-console-locations.conf.template | 11 ++++------- docker/images/files/nginx-entrypoint.sh | 13 ++++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index 2b0a4f95b8..a2a90913b0 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,14 +1,11 @@ -# The Admin Console is served at the root of its own server: strip the public -# path and admin-console/ from the original request URI. +# The Admin Console is served at the root of its own server. Keep proxy_pass +# without a URI: building it from $request_uri escapes the path twice. location /admin-console { - set $admin_console_uri "/"; - if ($request_uri ~ "^${PENPOT_PUBLIC_PATH_REGEX}admin-console(/.*)$") { - set $admin_console_uri $1; - } + rewrite "^/admin-console(?:/(.*))?$" /$1 break; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$admin_console_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index e141db2d3a..d60a5d691f 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -53,18 +53,17 @@ update_oidc_name /var/www/app/js/config.js # Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). # Requests below it are rewritten to the root, so the proxy in front of this -# server forwards them unchanged and $request_uri keeps the original path. +# server forwards them unchanged. PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" -# shellcheck disable=SC2016 # literal regex metacharacters -PENPOT_PUBLIC_PATH_REGEX="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" -export PENPOT_PUBLIC_PATH_REGEX - if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then + # shellcheck disable=SC2016 # literal regex metacharacters + _public_path_regex="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } - rewrite ^${PENPOT_PUBLIC_PATH_REGEX}(.*)\$ /\$1 last;" + rewrite ^${_public_path_regex}(.*)\$ /\$1 last;" + unset _public_path_regex else export PENPOT_PUBLIC_PATH_DIRECTIVES="" fi @@ -81,7 +80,7 @@ envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BO if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} - envsubst "\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_PUBLIC_PATH_REGEX" \ + envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf else rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf From d6e850e917f0c8a88880301ef1b0f905740ddab1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 14:35:12 +0200 Subject: [PATCH 130/325] :recycle: Use DS icon buttons for the bottom palette toggles (#12443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Use DS icon buttons for the bottom palette toggles The color palette and typographies toggles of the bottom palette were raw buttons with deprecated icons. Replace them with DS ghost icon buttons with small icons, as before, that show the open palette through aria-pressed. The layout flags are keywords, so they are coerced to a boolean for aria-pressed. Their title moves to the label, shown in the DS tooltip with the shortcut. The palette actions button spans the palette height, not a DS button size, so it stays raw; only its deprecated icon moves to the DS icon. Add a visual test that compares the palette with each toggle selected with no tolerance. AI-assisted-by: claude-opus-5-5 * :recycle: Move bottom palette styles off the deprecated styles The palette toggles are DS icon buttons, so their placeholders go away. The actions button keeps the tertiary button look through local state variables, and its icon takes the color from the button. Spacing, sizes, borders, opacity and the color aliases move to DS tokens and logical properties, except right and width of the palette, which are the transitioned properties. The grid template is split into areas, rows and columns, since Sass reads its slash as a division with a size variable. The z-index values came from the legacy global scale. The resize area, the toggles and the actions button had their own (4, 2 and 2), plus 0 overrides while collapsed, but they sit in separate grid areas and the palette content does not reach them: removing them changes no pixel with the text or color palette, the actions menu open, the 80px size, or the palette collapsed and hidden. Only the palette's own z-index stays, with a comment: it keeps the palette above the viewport overlays (z-index 1), and drops to their level while collapsed. The help button styles are not used in palette.cljs, but the external chat widget injected into the palette wrapper may use them, so they stay, inlined on DS tokens. palette.scss no longer imports refactor/. AI-assisted-by: claude-opus-5-5 * :recycle: Use a DS icon button for the bottom palette actions The palette actions button (⋮) stayed a raw button because it spans the palette height, not a DS button size. The DS icon button takes its height from --button-height, so a class now stretches it to the palette and the hand-made colors and states go away. It uses the ghost variant, like the palette toggles next to it. The button had no accessible name and did not say that it opens a menu: it now has the "Palette options" label (new en and es keys) with its tooltip, aria-expanded and aria-haspopup. The class given to its icon did not exist in palette.scss and is gone. Only the button corners change in the palette screenshots: the raw button painted its own rounded background, the ghost button is transparent at rest. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 34 +++ .../src/app/main/ui/workspace/palette.cljs | 42 ++-- .../src/app/main/ui/workspace/palette.scss | 225 ++++++++++-------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 192 insertions(+), 117 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index f324163b4a..6a7b7325e9 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -586,6 +586,40 @@ test.describe("Palette", () => { ), ).toBeVisible(); }); + + test("Shows the palette toggles selected", async ({ page }) => { + const workspace = new WorkspacePage(page); + const { fileId, pageId } = await setupFileWithAssets(workspace); + + await workspace.goToWorkspace({ fileId, pageId }); + + await workspace.palette + .getByRole("button", { name: "Typographies" }) + .click(); + await expect( + workspace.palette.getByText("Source Sans Pro Regular"), + ).toBeVisible(); + await page.mouse.move(640, 300); + // The toggles are a small part of the palette: compare every pixel. + await expect(workspace.palette).toHaveScreenshot("palette-text.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.palette + .getByRole("button", { name: "Color Palette" }) + .click(); + await expect( + workspace.palette.getByText( + "There are no color styles in your library yet", + ), + ).toBeVisible(); + await page.mouse.move(640, 300); + await expect(workspace.palette).toHaveScreenshot("palette-color.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); }); test.describe("Plugins", () => { diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 7887add48d..2f27304d97 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -18,9 +18,10 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as h] [app.main.ui.hooks.resize :as r] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.color-palette :refer [color-palette*]] [app.main.ui.workspace.color-palette-ctx-menu :refer [color-palette-ctx-menu*]] [app.main.ui.workspace.text-palette :refer [text-palette*]] @@ -95,6 +96,11 @@ (fn [_] (swap! state* assoc :show-menu false))) + on-toggle-menu + (mf/use-fn + (fn [_] + (swap! state* update :show-menu not))) + on-select-palette (mf/use-fn (mf/deps on-select) @@ -182,27 +188,31 @@ [:ul {:class (dm/str size-classname " " (stl/css-case :palette-btn-list true :hidden-bts hide-palettes?))} [:li {:class (stl/css :palette-item)} - [:button {:title (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) - :aria-label (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) - :class (stl/css-case :palette-btn true - :selected color-palette?) - :on-click on-select-color-palette} - deprecated-icon/drop-icon]] + [:> icon-button* {:variant "ghost" + :aria-pressed (boolean color-palette?) + :icon-size "s" + :aria-label (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) + :icon i/drop + :on-click on-select-color-palette}]] [:li {:class (stl/css :palette-item)} - [:button {:title (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) - :aria-label (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) - :class (stl/css-case :palette-btn true - :selected text-palette?) - :on-click on-select-text-palette} - deprecated-icon/text-palette]]] + [:> icon-button* {:variant "ghost" + :aria-pressed (boolean text-palette?) + :icon-size "s" + :aria-label (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) + :icon i/text-palette + :on-click on-select-text-palette}]]] (if any-palette? [:* - [:button {:class (stl/css :palette-actions) - :on-click #(swap! state* update :show-menu not)} - deprecated-icon/menu] + [:> icon-button* {:variant "ghost" + :class (stl/css :palette-actions) + :aria-label (tr "workspace.toolbar.palette-options") + :aria-expanded (boolean show-menu?) + :aria-haspopup "menu" + :icon i/menu + :on-click on-toggle-menu}] [:div {:class (stl/css :palette) :ref container} (when text-palette? diff --git a/frontend/src/app/main/ui/workspace/palette.scss b/frontend/src/app/main/ui/workspace/palette.scss index b61814d2ba..d743596d59 100644 --- a/frontend/src/app/main/ui/workspace/palette.scss +++ b/frontend/src/app/main/ui/workspace/palette.scss @@ -4,17 +4,16 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/spacing.scss" as *; -@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; .palette-wrapper { position: absolute; - width: 100vw; - left: 0; - bottom: 0; - padding-bottom: deprecated.$s-4; + inline-size: 100vw; + inset-inline-start: 0; + inset-block-end: 0; + padding-block-end: var(--sp-xs); /** Aligns AI Chat button **/ display: flex; @@ -23,20 +22,26 @@ gap: var(--sp-s); } +// `right` and `width` stay physical: they are the transitioned properties. .palettes { - z-index: deprecated.$z-index-2; + // Above .viewport-overlays (z-index 1), which covers the whole viewport. + z-index: 2; position: relative; right: 0; grid-area: color-palette; display: grid; - grid-template: "resize resize resize" deprecated.$s-8 "buttons actions palette" 1fr / deprecated.$s-32 auto 1fr; - max-height: deprecated.$s-80; - height: var(--height); + grid-template-areas: + "resize resize resize" + "buttons actions palette"; + grid-template-rows: px2rem(8) 1fr; + grid-template-columns: $sz-32 auto 1fr; + max-block-size: px2rem(80); + block-size: var(--height); width: fit-content; - padding: deprecated.$s-0 deprecated.$s-0 deprecated.$s-8 deprecated.$s-8; - border-radius: deprecated.$br-8; - background-color: var(--palette-background-color); - border: deprecated.$s-2 solid var(--panel-border-color); + padding: 0 0 var(--sp-s) var(--sp-s); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); transition: right 0.3s, opacity 0.2s, @@ -48,23 +53,21 @@ .resize-area { grid-area: resize; - height: deprecated.$s-8; - z-index: deprecated.$z-index-4; - width: calc(100% - deprecated.$s-8); - border-radius: deprecated.$br-circle; + block-size: px2rem(8); + inline-size: calc(100% - px2rem(8)); + border-radius: $br-circle; cursor: ns-resize; - background-color: var(--palette-background-color); + background-color: var(--color-background-primary); } .palette-btn-list { grid-area: buttons; - background-color: var(--palette-background-color); - height: calc(var(--height) - deprecated.$s-16); - width: deprecated.$s-32; - margin: deprecated.$s-0; + background-color: var(--color-background-primary); + block-size: calc(var(--height) - px2rem(16)); + inline-size: $sz-32; + margin: 0; list-style: none; - z-index: deprecated.$z-index-2; - gap: deprecated.$s-2; + gap: var(--sp-xxs); &.mid-palette, &.small-palette { @@ -72,151 +75,171 @@ } .palette-item { - @include deprecated.flex-center; - - border-radius: deprecated.$br-8; - opacity: deprecated.$op-10; + display: flex; + justify-content: center; + align-items: center; + border-radius: $br-8; + opacity: 1; transition: opacity 1s ease; - - .palette-btn { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-32; - border-radius: deprecated.$br-8; - background-clip: padding-box; - padding: 0; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - - &.selected { - @extend %button-icon-selected; - } - } } } + // DS icon button stretched to the palette height. .palette-actions { - @extend %button-tertiary; + --button-height: calc(var(--height) - #{px2rem(16)}); grid-area: actions; - height: calc(var(--height) - deprecated.$s-16); - width: deprecated.$s-32; - padding: 0; - margin-left: deprecated.$s-4; - border-radius: deprecated.$br-8; - background-color: var(--palette-background-color); - z-index: deprecated.$z-index-2; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + margin-inline-start: var(--sp-xs); } .palette { grid-area: palette; - width: 100%; - min-width: 0; + inline-size: 100%; + min-inline-size: 0; } } .handler { - @include deprecated.button-style; - @include deprecated.flex-center; - - width: deprecated.$s-12; - height: 100%; + display: flex; + justify-content: center; + align-items: center; + border: none; + background: none; + cursor: pointer; + inline-size: $sz-12; + block-size: 100%; .handler-btn { - width: deprecated.$s-4; - height: 100%; - max-height: deprecated.$s-40; - margin: deprecated.$s-8 deprecated.$s-4; + inline-size: px2rem(4); + block-size: 100%; + max-block-size: $sz-40; + margin: var(--sp-s) var(--sp-xs); padding: 0; - border-radius: deprecated.$s-4; - background-color: var(--palette-handler-background-color); + border-radius: $br-4; + background-color: var(--color-background-quaternary); } } .mid-palette, .small-palette { - grid-template-columns: deprecated.$s-64 auto 1fr; + grid-template-columns: px2rem(64) auto 1fr; } .hidden-bts { - right: deprecated.$s-2; - z-index: deprecated.$z-index-1; - width: 22px; - grid-template-columns: deprecated.$s-8 auto 1fr; + right: px2rem(2); + + // Back to the .viewport-overlays level (z-index 1) while collapsed. + z-index: 1; + width: px2rem(22); + grid-template-columns: px2rem(8) auto 1fr; padding: 0; border-inline-start: 0; border-start-start-radius: 0; border-end-start-radius: 0; .palette-btn-list { - opacity: deprecated.$op-0; + opacity: 0; visibility: hidden; - width: 0; + inline-size: 0; .palette-item { - opacity: deprecated.$op-0; + opacity: 0; visibility: hidden; - z-index: 0; } } .resize-area { visibility: hidden; - z-index: 0; - width: 0; + inline-size: 0; } .palette-actions { visibility: hidden; - z-index: 0; } .palette { visibility: hidden; - z-index: 0; } .handler { - padding-bottom: deprecated.$s-8; + padding-block-end: var(--sp-s); } } /** AI Chat button styles **/ +// Not used in palette.cljs: the external chat widget (Inkeep, see #6509) +// injected into #palette-wrapper may use these class names, so they stay. .help-btn { + --help-btn-foreground-color: var(--color-foreground-secondary); + --help-btn-background-color: var(--color-background-tertiary); + z-index: var(--z-index-panels); flex-shrink: 0; - - @extend %button-secondary; - + display: flex; + justify-content: center; + align-items: center; inline-size: $sz-40; block-size: $sz-40; - border-radius: deprecated.$br-circle; border: none; + border-radius: $br-circle; + background-color: var(--help-btn-background-color); + color: var(--help-btn-foreground-color); + cursor: pointer; - &.selected { - @extend %button-icon-selected; + // The widget markup is external, so its icon has no known class. + svg { + stroke: var(--help-btn-foreground-color); } &:hover { - border: none; + --help-btn-foreground-color: var(--color-accent-primary); + --help-btn-background-color: var(--color-background-quaternary); + + text-decoration: none; + } + + &:active { + --help-btn-foreground-color: var(--color-accent-primary); + --help-btn-background-color: var(--color-background-secondary); + + outline: none; + border: $b-1 solid var(--color-background-quaternary); + } + + &:focus-visible { + --help-btn-foreground-color: var(--color-foreground-secondary); + --help-btn-background-color: var(--color-background-tertiary); + + outline: none; + border: $b-1 solid var(--color-accent-primary); + } + + &:global(.disabled), + &[disabled], + &:disabled { + --help-btn-foreground-color: var(--color-foreground-disabled); + --help-btn-background-color: var(--color-background-quaternary); + + border: $b-1 solid var(--color-background-quaternary); + cursor: unset; + } + + &.selected { + --help-btn-foreground-color: var(--color-accent-primary); + --help-btn-background-color: var(--color-background-quaternary); + + outline: none; } } .icon-help { - @extend %button-icon; - - stroke: var(--icon-foreground); + display: flex; + justify-content: center; + align-items: center; inline-size: var(--sp-xxl); block-size: var(--sp-xxl); + color: transparent; + fill: none; + stroke-width: 1px; + stroke: var(--color-foreground-secondary); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 9e4020a717..078f9c694b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9354,6 +9354,10 @@ msgstr "Move (%s)" msgid "workspace.toolbar.palette-bar" msgstr "Palette bar" +#: src/app/main/ui/workspace/palette.cljs:211 +msgid "workspace.toolbar.palette-options" +msgstr "Palette options" + #: src/app/main/ui/workspace/top_toolbar.cljs:72 msgid "workspace.toolbar.path" msgstr "Path (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1cf8ab2e51..23f9f2f47c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9460,6 +9460,10 @@ msgstr "Mover (%s)" msgid "workspace.toolbar.palette-bar" msgstr "Barra de paletas" +#: src/app/main/ui/workspace/palette.cljs:211 +msgid "workspace.toolbar.palette-options" +msgstr "Opciones de la paleta" + #: src/app/main/ui/workspace/top_toolbar.cljs:72 msgid "workspace.toolbar.path" msgstr "Ruta (%s)" From 6acb360f4ad5dc24faec9d67f1fc8725456755de Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 21 Oct 2026 15:59:31 +0200 Subject: [PATCH 131/325] :recycle: Use DS icons and an icon button in the undo history panel (#12472) * :recycle: Use DS icons and an icon button in the undo history panel The undo history panel (History > Actions) drew its entry type icons with the deprecated icon set, and the arrow that shows the details of an entry was a div with a click handler: no button role, no keyboard access and no accessible name. - The entry type icons use the DS icon (drop-icon is i/drop in the DS). - The arrow becomes a DS action icon button labeled "Show details", with aria-expanded for its state. It keeps its data attribute, its classes and its click handler, and it also shows on keyboard focus. The DS icon draws 12px inside a 16px box, so the entry icon gets a negative margin, and the button wrapper a negative end margin: icon, arrow and text stay where they were. The arrow turns through its own class instead of an svg rule. Add the workspace.undo.entry.show-details key (en, es) and a visual test that draws a rectangle, opens the actions list and expands the entry. AI-assisted-by: claude-opus-5-5 * :recycle: Move undo history panel styles off the deprecated styles Move history.scss to DS tokens, sizes, borders and logical properties, and drop the refactor import. The entry color aliases become local variables of the entry, set by its disabled and hover states, and the arrow opacity goes through one as well. AI-assisted-by: claude-opus-5-5 * :lipstick: Place the undo history icon and arrow without negative margins Drop the -2px margin of the entry icon and the -6px end margin of the details button wrapper. Keep text and arrow in place with token spacing instead: the text after the 16px icon box starts with --sp-xs instead of --sp-s, and the entry end padding is --sp-xxs, since the 24px button leaves 6px around its 12px arrow. The icon now sits 2px to the right, centered in its box. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 36 +++++++ .../main/ui/workspace/sidebar/history.cljs | 53 +++++----- .../main/ui/workspace/sidebar/history.scss | 98 +++++++++---------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 120 insertions(+), 75 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 6a7b7325e9..92ac8dddb0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -560,6 +560,42 @@ test.describe("Right header", () => { }); }); +test.describe("Undo history", () => { + test("Shows the actions list with an entry expanded", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + await workspace.mockRPC( + "get-file-snapshots?file-id=*", + "workspace/versions-snapshot-1.json", + ); + await workspace.goToWorkspace(); + + await page.keyboard.press("r"); + await workspace.clickWithDragViewportAt(128, 128, 200, 100); + + await page.keyboard.press("Control+Alt+h"); + await page.getByRole("tab", { name: "Actions" }).click(); + const panel = page.getByRole("tabpanel", { name: "Actions" }); + const entry = panel.getByText("New rectangle"); + // The short id and the relative time change on every run. + const mask = [page.locator('[class*="history-entry-meta"]')]; + + // The expand arrow only shows while the entry is hovered. + await entry.hover(); + await expect(panel).toHaveScreenshot({ mask }); + + // The data attribute exists before and after the DS migration. + await panel.locator('[data-has-entry="true"]').click(); + await expect(panel.getByText("Rectangle", { exact: true })).toBeVisible(); + await entry.hover(); + await expect(panel).toHaveScreenshot({ mask }); + }); +}); + test.describe("Palette", () => { test("Shows the bottom palette expanded and collapsed", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index 09e8e68583..293a79ce1e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -13,9 +13,9 @@ [app.main.data.workspace.undo :as dwu] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.product.empty-state :refer [empty-state*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :refer [tr] :as i18n] [cuerdas.core :as str] @@ -155,22 +155,23 @@ :move (tr "workspace.undo.entry.move" value) (tr "workspace.undo.entry.unknown" value)))) -(defn entry->icon [{:keys [type]}] +(defn- entry->icon-id + [{:keys [type]}] (case type - :page deprecated-icon/document - :shape deprecated-icon/svg - :rect deprecated-icon/rectangle - :circle deprecated-icon/ellipse - :text deprecated-icon/text - :path deprecated-icon/path - :frame deprecated-icon/board - :group deprecated-icon/group - :color deprecated-icon/drop-icon - :typography deprecated-icon/text-palette - :component deprecated-icon/component - :media deprecated-icon/img - :image deprecated-icon/img - deprecated-icon/svg)) + :page i/document + :shape i/svg + :rect i/rectangle + :circle i/ellipse + :text i/text + :path i/path + :frame i/board + :group i/group + :color i/drop + :typography i/text-palette + :component i/component + :media i/img + :image i/img + i/svg)) (defn is-shape? [type] (contains? #{:shape :rect :circle :text :path :frame :group} type)) @@ -339,7 +340,7 @@ [:div {:class (stl/css :history-entry-summary)} [:div {:class (stl/css :history-entry-summary-icon)} - (entry->icon entry)] + [:> icon* {:icon-id (entry->icon-id entry) :size "s"}]] [:div {:class (stl/css :history-entry-summary-text)} [:div {:class (stl/css :history-entry-title)} (entry->message entry)] @@ -363,11 +364,17 @@ [:span {:class (stl/css :history-entry-author)} (tr "workspace.undo.entry.by" author)])])] (when (:detail entry) - [:div {:class (stl/css-case :history-entry-summary-button true - :button-opened @show-detail?) - :on-click toggle-show-detail - :data-has-entry (dm/str (not (nil? (:detail entry))))} - deprecated-icon/arrow])] + [:> icon-button* {:variant "action" + :class (stl/css-case :history-entry-summary-button true + :button-opened @show-detail?) + :tooltip-class (stl/css :history-entry-summary-button-wrapper) + :aria-label (tr "workspace.undo.entry.show-details") + :aria-expanded @show-detail? + :icon i/arrow + :icon-size "s" + :icon-class (stl/css :history-entry-summary-arrow) + :on-click toggle-show-detail + :data-has-entry (dm/str (not (nil? (:detail entry))))}])] (when @show-detail? [:> history-entry-details* {:entry entry}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.scss b/frontend/src/app/main/ui/workspace/sidebar/history.scss index e7e8b1a2cd..59d56a64ad 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/history.scss @@ -4,13 +4,14 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; .history-toolbox { display: flex; flex-direction: column; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } .history-entry-empty { @@ -22,21 +23,29 @@ } .history-entries { - height: calc(100vh - deprecated.$s-100); - padding: deprecated.$s-12; + block-size: calc(100vh - #{px2rem(100)}); + padding: var(--sp-m); overflow: hidden auto; - font-size: deprecated.$fs-12; + font-size: px2rem(12); } .history-entry { + --entry-background-color: var(--color-background-tertiary); + --entry-foreground-color: var(--color-foreground-secondary); + --entry-border-color: transparent; + --entry-arrow-opacity: 0; + display: flex; justify-content: center; flex-direction: column; - min-height: deprecated.$s-32; - margin: deprecated.$s-4; - padding: deprecated.$s-4 deprecated.$s-8; - border: deprecated.$s-2 solid transparent; - border-radius: deprecated.$s-8; + min-block-size: $sz-32; + margin: var(--sp-xs); + + // The end padding is small because the details button leaves 6px + // around its 12px arrow. + padding: var(--sp-xs) var(--sp-xxs) var(--sp-xs) var(--sp-s); + border: $b-2 solid var(--entry-border-color); + border-radius: $br-8; background-color: var(--entry-background-color); cursor: pointer; transition: border 0.2s; @@ -46,18 +55,18 @@ align-items: center; .history-entry-summary-icon { - svg { - @extend %button-icon-small; - - stroke: var(--entry-foreground-color); - } + display: flex; + color: var(--entry-foreground-color); } .history-entry-summary-text { display: flex; flex-direction: column; - gap: deprecated.$s-2; - margin: 0 deprecated.$s-8; + gap: var(--sp-xxs); + + // The DS icon draws 12px inside a 16px box: a smaller start margin + // keeps the text where it was. + margin-inline: var(--sp-xs) var(--sp-s); color: var(--color-foreground-primary); min-inline-size: 0; } @@ -73,7 +82,7 @@ // template ("hash · time by Name") survive rendering. Issue #7660. .history-entry-meta { color: var(--color-foreground-secondary); - font-size: deprecated.$fs-10; + font-size: px2rem(10); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -99,28 +108,28 @@ white-space: nowrap; } - .history-entry-summary-button { - opacity: deprecated.$op-0; - margin-left: auto; + // Tooltip wrapper of the details button, pushed to the end of the row. + .history-entry-summary-button-wrapper { + margin-inline-start: auto; + } - &.button-opened { - svg { - transform: rotate(90deg); - } + .history-entry-summary-button { + opacity: var(--entry-arrow-opacity); + + &:focus-visible { + --entry-arrow-opacity: 1; } - svg { - @extend %button-icon-small; - - stroke: var(--entry-foreground-color); + &.button-opened .history-entry-summary-arrow { + transform: rotate(90deg); } } } .history-entry-detail { display: block; - padding-top: deprecated.$s-16; - color: var(--modal-text-foreground-color); + padding-block-start: var(--sp-l); + color: var(--color-foreground-secondary); .history-entry-details-list { margin: 0; @@ -128,31 +137,16 @@ } &.disabled { - border-color: var(--entry-border-color-disabled); - background-color: var(--entry-background-color-disabled); + --entry-background-color: var(--color-background-primary); + --entry-border-color: var(--color-background-quaternary); } &.hover, &:hover { - background-color: var(--entry-background-color-hover); - color: var(--entry-foreground-color-hover); + --entry-background-color: var(--color-background-quaternary); + --entry-foreground-color: var(--color-foreground-primary); + --entry-arrow-opacity: 1; - .history-entry-summary { - .history-entry-summary-icon { - svg { - stroke: var(--entry-foreground-color-hover); - } - } - - .history-entry-summary-button { - opacity: deprecated.$op-10; - - &.button-opened { - svg { - transform: rotate(90deg); - } - } - } - } + color: var(--color-foreground-primary); } } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 078f9c694b..040339f8ef 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9407,6 +9407,10 @@ msgstr "There are no history changes so far" msgid "workspace.undo.entry.by" msgstr "by %s" +#: src/app/main/ui/workspace/sidebar/history.cljs +msgid "workspace.undo.entry.show-details" +msgstr "Show details" + #: src/app/main/ui/workspace/sidebar/history.cljs:154 msgid "workspace.undo.entry.delete" msgstr "Deleted %s" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 23f9f2f47c..e369ed0b12 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9513,6 +9513,10 @@ msgstr "Todavía no hay cambios en el histórico" msgid "workspace.undo.entry.by" msgstr "por %s" +#: src/app/main/ui/workspace/sidebar/history.cljs +msgid "workspace.undo.entry.show-details" +msgstr "Mostrar detalles" + #: src/app/main/ui/workspace/sidebar/history.cljs:154 msgid "workspace.undo.entry.delete" msgstr "%s eliminado" From 347a52d51465b35d77b48b6d1a3fbd47c8b861f0 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 22 Oct 2026 11:07:07 +0200 Subject: [PATCH 132/325] :bug: Keep Path out of update-team-photo audit props (#12530) Nitrate encodes audit chunks as JSON and cannot serialize java.nio.file.Path; mirror update-profile-photo and replace the upload map with safe props. --- backend/src/app/rpc/commands/teams.clj | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index a39ba9572b..041516d3a9 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -1104,7 +1104,7 @@ (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo - [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}] + [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id file] :as params}] (let [team (get-team pool :profile-id profile-id :team-id team-id) photo (profile/upload-photo cfg params)] @@ -1120,4 +1120,10 @@ {:photo-id (:id photo)} {:id team-id}) - (assoc team :photo-id (:id photo)))) + (rph/with-meta (assoc team :photo-id (:id photo)) + {::audit/replace-props + {:team-id team-id + :file-name (:filename file) + :file-size (:size file) + :file-path (str (:path file)) + :file-mtype (:mtype file)}}))) From 0505eb3b1dfca04f67d5f036650d6bb279098c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 22 Oct 2026 13:39:26 +0200 Subject: [PATCH 133/325] :bug: Fix pasted gradient fills in text editor v3 (#12516) * :bug: Keep text gradients in place when pasting at the caret Text gradients are stored as fractions of the whole text box. Paste copied those fractions unchanged, so in a box of another size the handles and colours moved away from the pasted text. Copy from the v3 editor now records where each span with a gradient or image fill starts in its text box, and the box size, in the clipboard payload. A new WASM export, text_editor_get_range_origin, gives the start of any text range. Paste at the caret finds where those spans start in the target and rewrites their gradients so the handles keep the same pixel offset from the span. Auto-sized texts use the box size they get when the editor closes. AI-assisted-by: claude-opus-5-5 * :bug: Rebuild text gradient shaders when the text box resizes Text gradient and image shaders are built into the cached Skia paragraphs against the text box size. A resize with no content change reused those paragraphs, so the colours stayed matched to the old box until a reload. The text now remembers the box size its shaders were built for and lays itself out again when that size changes and a span has a gradient or image fill. Text with only solid fills keeps its cache. AI-assisted-by: claude-opus-5-5 * :bug: Keep text gradients in place when pasting on the canvas A text selection pasted on the canvas becomes a new text whose box differs from the source, so its gradients moved like they did for caret paste. After adding the new text, lay it out, find where its gradient spans start and move their gradients to keep the same pixel offset from the span, in the same undo step. The range origin export now takes a shape id, so it works on texts that are not being edited. AI-assisted-by: claude-opus-5-5 * :bug: Keep text gradients in place when pasting copied texts at the caret Text shapes copied whole, rather than as an editor selection, reached the caret paste without any geometry, so their gradients still moved away from the pasted text. A shape copy now records where the gradient and image spans of each selected text start, and the box size, by shape id. Paste joins them in the order of the joined paragraphs, with no origin for texts copied without one. Copied texts each have their own box, so every span origin now carries its own box size instead of one box for the whole payload. AI-assisted-by: claude-opus-5-5 --- .../app/main/data/workspace/clipboard.cljs | 30 ++-- .../src/app/main/data/workspace/texts.cljs | 121 +++++++++++++--- frontend/src/app/render_wasm/text_editor.cljs | 60 ++++++++ frontend/src/app/render_wasm/text_paste.cljs | 47 ++++++ frontend/src/app/util/text/clipboard.cljs | 20 +-- .../data/workspace_texts_test.cljs | 30 ++++ .../text_editor_content_range_test.cljs | 16 +++ .../render_wasm/text_paste_test.cljs | 72 ++++++++++ .../util_text_clipboard_test.cljs | 9 ++ render-wasm/src/render/text_editor.rs | 134 ++++++++++++++++-- render-wasm/src/shapes/text.rs | 97 ++++++++++++- render-wasm/src/wasm/text_editor.rs | 54 +++++++ 12 files changed, 644 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 557747d080..4796ab969c 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -209,14 +209,18 @@ file (dsh/lookup-file state file-id) version (get file :version) - initial {:type :copied-shapes - :features features - :version version - :file-id file-id - :team-id (:current-team-id state) - :selected selected - :objects {} - :images #{}} + geometry (dwtxt/copied-text-geometry state objects selected) + + initial (cond-> {:type :copied-shapes + :features features + :version version + :file-id file-id + :team-id (:current-team-id state) + :selected selected + :objects {} + :images #{}} + (some? geometry) + (assoc :text-geometry geometry)) shapes (->> (cfh/selected-with-children objects selected) (keep (d/getf objects)))] @@ -584,6 +588,7 @@ [:objects [:map-of ::sm/uuid :map]] [:images [:set :map]] + [:text-geometry {:optional true} [:map-of ::sm/uuid :map]] [:position {:optional true} ::gpt/point]]) (def ^:private @@ -1075,9 +1080,11 @@ (ptk/reify ::paste-html-text ptk/WatchEvent (watch [_ state _] - (let [[text content] + (let [payload (when (v3-html-paste? state) + (text-clipboard/html->payload html)) + [text content] (if (v3-html-paste? state) - (if-let [payload (text-clipboard/html->payload html)] + (if (some? payload) (let [content (dwtxt/payload-content state payload)] [(txt/content->text content) content]) (when-let [fragment (some-> (text-clipboard/html->fragment html) @@ -1109,7 +1116,8 @@ (dwsh/create-and-add-shape :text x y shape (when skip-edition? {:skip-edition? true}))) (if skip-edition? - (rx/of (dwwt/resize-wasm-text-debounce id {:undo-group id + (rx/of (dwtxt/move-new-text-gradients id payload) + (dwwt/resize-wasm-text-debounce id {:undo-group id :undo-id undo-id})) (rx/of (dwu/commit-undo-transaction undo-id)))))))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 0acbe4e92f..8ac273e1d2 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -17,6 +17,7 @@ [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] [app.common.math :as mth] + [app.common.render-wasm.api.select :as wselect] [app.common.transit :as t] [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] @@ -1563,13 +1564,17 @@ (wasm.text-editor/text-editor-has-selection?)) (let [text (wasm.text-editor/text-editor-export-selection)] (when (seq text) - (let [content (when (features/active-feature? state "text-editor-wasm/v1-html-paste") - (-> (wasm.text-editor/text-editor-get-active-shape-id) - (wasm.text-editor/selection-content))) - payload (when (some? content) - (text-clipboard/payload->html content - (:current-file-id state) - (:current-team-id state)))] + (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) + content (when (features/active-feature? state "text-editor-wasm/v1-html-paste") + (wasm.text-editor/selection-content shape-id)) + geometry (when (some? content) + (wasm.text-editor/content-geometry shape-id content + (wasm.text-editor/selection-start))) + payload (when (some? content) + (text-clipboard/payload->html content + (:current-file-id state) + (:current-team-id state) + geometry))] {"text/plain" text "text/html" (clipboard/plain-text->html text payload)}))))) @@ -1634,25 +1639,51 @@ (when (and (map? data) (= :copied-shapes (:type data))) data)))) +(defn copied-text-geometry + "The geometry (see `text-editor/content-geometry`) of each selected text in + `objects` with gradient or image spans, by id, for a shape copy; nil when none." + [state objects selected] + (when (features/active-feature? state "text-editor-wasm/v1-html-paste") + (not-empty + (into {} + (keep (fn [id] + (let [{:keys [content] :as shape} (get objects id)] + (when (cfh/text-shape? shape) + (some->> (wasm.text-editor/content-geometry id content {:para 0 :offset 0}) + (vector id)))))) + selected)))) + +(defn- text-origins + "The span origins of `text` in `text-geometry`, nil for each span without one." + [text-geometry {:keys [id content]}] + (or (get-in text-geometry [id :origins]) + (mapv #(mapv (constantly nil) (:children %)) + (-> content :children first :children)))) + (defn- copied-shapes->payload "A text payload joining the content of the copied text shapes, top to bottom and left to right; nil when none of the copied shapes is a text." - [{:keys [objects selected file-id team-id]}] + [{:keys [objects selected file-id team-id text-geometry]}] (let [texts (->> selected (keep #(get objects %)) (filter cfh/text-shape?) (sort-by (juxt :y :x)))] (when (seq texts) - {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) texts))}))) + (cond-> {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))} + (some? text-geometry) + (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))))) (defn- penpot-paste [state payload] - {:penpot? true - :fragment (text-paste/content->fragment (payload-content state payload))}) + (let [content (payload-content state payload)] + {:penpot? true + :content content + :geometry (:geometry payload) + :fragment (text-paste/content->fragment content)})) (defn- clipboard->paste "What to paste for clipboard `html` and `text`, or nil: text and text shapes copied @@ -1676,10 +1707,63 @@ {:penpot? false :fragment fragment})))) +(defn- target-geometry + "Where the gradient spans of `pasted`, inserted at `start`, begin in `shape` with + `content`, laid over the box it gets once resized to its text; nil when none." + [{:keys [id grow-type]} content pasted start] + (when-let [geometry (wasm.text-editor/content-geometry id pasted start)] + (if (= :fixed grow-type) + geometry + ;; Auto-sized boxes only fit their text when resized (for the editor, when it + ;; closes), which drops the vertical-align offset in the measured origins. + (let [{:keys [width height]} (wasm.api/get-text-dimensions id) + fit (fn [{:keys [box] :as origin}] + (let [offset (case (some-> (:vertical-align content) name) + "center" (/ (- (:height box) height) 2) + "bottom" (- (:height box) height) + 0)] + (-> origin + (update :y - offset) + (assoc :box {:width (if (= :auto-width grow-type) width (:width box)) + :height height}))))] + (update geometry :origins (partial mapv (partial mapv #(some-> % fit)))))))) + +(defn- move-pasted-gradients + "`content`, the shape's after restyling the paste, with the pasted gradients + keeping their place on their spans. Pushes the result to WASM." + [shape content {:keys [geometry] :as paste} start] + (let [pasted (:content paste) + target (when (some? geometry) (target-geometry shape content pasted start))] + (if (some? target) + (let [content (text-paste/move-gradients content pasted start geometry target)] + (wasm.api/set-shape-text-content (:id shape) content) + content) + content))) + +(defn move-new-text-gradients + "Event moving the gradients of `payload`, just pasted on the canvas as the text + `id`, so they keep their place on their spans." + [id {:keys [geometry]}] + (ptk/reify ::move-new-text-gradients + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [content] :as shape} (dsh/lookup-shape state id) + start {:para 0 :offset 0} + target (when (and (some? shape) (some? geometry) (wasm.api/initialized?)) + (wselect/use-shape id) + (wasm.api/set-shape-text-content id content) + (target-geometry shape content content start))] + (if (some? target) + (rx/of (dwsh/update-shapes + [id] + #(update % :content text-paste/move-gradients content start geometry target) + {:attrs #{:content}})) + (rx/empty)))))) + (defn- insert-fragment "Insert the text of `fragment` at the caret and restyle it: with its own styles or overrides, else with the pending caret style. Returns the event saving it." - [{:keys [fragment penpot?]}] + [state {:keys [fragment penpot?] :as paste}] (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) start (wasm.text-editor/selection-start) styled? (or penpot? (text-paste/styled? fragment)) @@ -1696,7 +1780,8 @@ ;; Sync first so the cached content stays index-aligned with WASM. (wasm.text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] - (v3-update-editor-content shape-id content) + (let [shape (dsh/lookup-shape state shape-id)] + (v3-update-editor-content shape-id (move-pasted-gradients shape content paste start))) (v3-sync-editor-content))) pending? @@ -1713,7 +1798,7 @@ (watch [_ state _] (if-let [paste (when (wasm.text-editor/text-editor-has-focus?) (clipboard->paste state html text))] - (let [event (insert-fragment paste)] + (let [event (insert-fragment state paste)] (wasm.api/request-render-preserving-target "text-paste") (if (some? event) (rx/of event) (rx/empty))) (rx/empty))))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index c7e529ec6f..4348117ed8 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -838,6 +838,66 @@ (when-not (and (= start-para end-para) (= start-offset end-offset)) (content-range content range)))))) +(defn span-ranges + "The char range of every span of `content`, a slice that starts at `start` + (`{:para :offset}`) in its shape: one vector of ranges per paragraph." + [content {:keys [para offset]}] + (into [] + (map-indexed + (fn [idx paragraph] + (let [para-idx (+ para idx) + spans (:children paragraph) + starts (reductions + (if (zero? idx) offset 0) (map (comp count :text) spans))] + (mapv (fn [span span-start] + {:start-para para-idx + :start-offset span-start + :end-para para-idx + :end-offset (+ span-start (count (:text span)))}) + spans + starts)))) + (-> content :children first :children))) + +(defn- range-origin + "Where the text in `range` starts in the text `shape-id`, shape-local, with the size + of the box its fills are laid over: {:x :y :box {:width :height}}, or nil." + [shape-id {:keys [start-para start-offset end-para end-offset]}] + (when (wasm/ready?) + (let [id (uuid/get-u32 shape-id) + byte-offset (mem/alloc 16) + f32-offset (mem/->offset-32 byte-offset) + heap (mem/get-heap-f32) + has-text? (h/call wasm/internal-module "_text_get_range_origin" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + start-para start-offset end-para end-offset byte-offset) + result (when has-text? + {:x (aget heap f32-offset) + :y (aget heap (+ f32-offset 1)) + :box {:width (aget heap (+ f32-offset 2)) + :height (aget heap (+ f32-offset 3))}})] + (mem/free) + result))) + +(defn- box-relative-fill? + [fill] + (or (some? (:fill-color-gradient fill)) + (some? (:fill-image fill)))) + +(defn content-geometry + "Where the gradient or image spans of `content`, a slice starting at `start`, begin + in the text `shape-id`, each with its box size; nil when it has none." + [shape-id content start] + (let [ranges (span-ranges content start) + origins (mapv (fn [paragraph paragraph-ranges] + (mapv (fn [span range] + (when (some box-relative-fill? (:fills span)) + (range-origin shape-id range))) + (:children paragraph) + paragraph-ranges)) + (-> content :children first :children) + ranges)] + (when (some some? (flatten origins)) + {:origins origins}))) + (defn- clean-styles "Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail the backend schema); a per-span fn is passed through untouched. A nil diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs index dbdd6cffec..4520327ce6 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -126,3 +126,50 @@ (update-in content [:children 0 :children (+ (:para start) idx)] restyle attrs)) content (rest (map-indexed vector fragment))))) + +;; --- Gradients of text copied in Penpot + +(defn- move-gradient + "`gradient`, in fractions of the `from` box, in fractions of the `to` box, with its + points at the same pixel offset from the span origin." + [gradient from to] + (let [{x0 :x y0 :y {w0 :width h0 :height} :box} from + {x1 :x y1 :y {w1 :width h1 :height} :box} to + move-x #(/ (+ x1 (- (* % w0) x0)) w1) + move-y #(/ (+ y1 (- (* % h0) y0)) h1)] + (cond-> (-> gradient + (update :start-x move-x) + (update :start-y move-y) + (update :end-x move-x) + (update :end-y move-y)) + ;; A radial width is scaled by the box aspect ratio when drawn. + (= :radial (:type gradient)) + (update :width #(/ (* % w0 h1) (* h0 w1)))))) + +(defn- move-fills + [span from to] + (update span :fills + (fn [fills] + (mapv #(cond-> % + (some? (:fill-color-gradient %)) + (update :fill-color-gradient move-gradient from to)) + fills)))) + +(defn- sized? + [{{:keys [width height]} :box}] + (and (pos? width) (pos? height))) + +(defn move-gradients + "`content` with the gradients of `pasted` (the content inserted at `start`) moved + from the `source` geometry to the `target` one (see `text-editor/content-geometry`)." + [content pasted start source target] + (let [entries (map vector + (flatten (text-editor/span-ranges pasted start)) + (flatten (:origins source)) + (flatten (:origins target)))] + (reduce (fn [content [range from to]] + (if (and (some? from) (some? to) (sized? from) (sized? to)) + (text-editor/apply-styles-over-range content range #(move-fills % from to)) + content)) + content + entries))) diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index f0f76bee6b..35df340afe 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -291,14 +291,18 @@ (.decode (js/TextDecoder.)))) (defn payload->html - "Hidden markup carrying `content`, copied from `file-id` in `team-id`." - [content file-id team-id] - (let [payload {:type :copied-text - :version 1 - :file-id file-id - :team-id team-id - :content content}] - (str ""))) + "Hidden markup carrying `content`, copied from `file-id` in `team-id`, and the + `geometry` of its spans in their text box (see `text-editor/content-geometry`)." + ([content file-id team-id] + (payload->html content file-id team-id nil)) + ([content file-id team-id geometry] + (let [payload (cond-> {:type :copied-text + :version 1 + :file-id file-id + :team-id team-id + :content content} + (some? geometry) (assoc :geometry geometry))] + (str "")))) (defn html->payload "The Penpot payload in `html`, or nil when it has none." diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 9fe41428e7..eac8693cff 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -626,3 +626,33 @@ result (dwt/merge-live-text-dimensions :auto-width selrect {:width 0 :height 0})] (t/is (= 50 (:width result))) (t/is (= 25 (:height result)))))) + +;; --------------------------------------------------------------------------- +;; copied-shapes->payload +;; --------------------------------------------------------------------------- + +(defn- copied-text + [id y & spans] + {:id id + :type :text + :x 0 + :y y + :content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children (mapv #(hash-map :text %) spans)}]}]}}) + +(t/deftest copied-shapes-payload-joins-text-geometry + (let [top-id (uuid/next) + bottom-id (uuid/next) + origin {:x 4 :y 2 :box {:width 100 :height 20}} + data {:file-id (uuid/next) + :selected #{bottom-id top-id} + :objects {top-id (copied-text top-id 0 "a" "b") + bottom-id (copied-text bottom-id 50 "c")}}] + (t/testing "the origins follow the joined paragraphs, nil for texts without geometry" + (t/is (= {:origins [[nil nil] [origin]]} + (:geometry (#'dwt/copied-shapes->payload + (assoc data :text-geometry {bottom-id {:origins [[origin]]}})))))) + (t/testing "a copy without geometry gives a payload without it" + (t/is (not (contains? (#'dwt/copied-shapes->payload data) :geometry)))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs index 675b1ea18a..6b6445f362 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs @@ -54,3 +54,19 @@ (text-editor/content-range (content (paragraph {} (span "a😀b" bold))) {:start-para 0 :start-offset 1 :end-para 0 :end-offset 4}))))) + +(t/deftest span-ranges + (let [slice (text-editor/content-range source {:start-para 0 :start-offset 3 + :end-para 1 :end-offset 5})] + (t/testing "the first paragraph's spans start at the slice start; later ones at 0" + (t/is (= [[{:start-para 0 :start-offset 3 :end-para 0 :end-offset 6} + {:start-para 0 :start-offset 6 :end-para 0 :end-offset 10} + {:start-para 0 :start-offset 10 :end-para 0 :end-offset 14}] + [{:start-para 1 :start-offset 0 :end-para 1 :end-offset 3} + {:start-para 1 :start-offset 3 :end-para 1 :end-offset 5}]] + (text-editor/span-ranges slice {:para 0 :offset 3}))))) + + (t/testing "offsets count UTF-16 units like WASM's" + (t/is (= [[{:start-para 2 :start-offset 1 :end-para 2 :end-offset 4}]] + (text-editor/span-ranges (content (paragraph {} (span "😀b" bold))) + {:para 2 :offset 1}))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs index 795309aff3..c9bc145647 100644 --- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -190,3 +190,75 @@ (t/is (= [[(span "A") (assoc brand :text "x")] [(assoc brand :text "y") (span "B")]] (spans-of result)))))) + +;; --- Gradients + +(defn- gradient-fill [gradient] + {:fills [{:fill-color-gradient (merge {:type :linear :width 1 :stops []} gradient)}]}) + +(def ^:private source-box {:width 200 :height 40}) + +(def ^:private target-box {:width 100 :height 80}) + +(def ^:private source-geometry + {:origins [[{:x 50 :y 0 :box source-box}]]}) + +(def ^:private target-geometry + {:origins [[{:x 10 :y 40 :box target-box}]]}) + +(defn- pasted-gradient [content] + (-> (spans-of content) first second :fills first :fill-color-gradient)) + +(t/deftest gradients-keep-their-offset-from-the-span + (let [linear (gradient-fill {:start-x 0.25 :start-y 0 :end-x 0.75 :end-y 1}) + pasted (content [(span "HELLO" linear)]) + target (content [(span "abc") (span "HELLO" linear)]) + result (text-paste/move-gradients target pasted {:para 0 :offset 3} + source-geometry target-geometry)] + (t/testing "points move into the target box at the same pixel offsets" + (t/is (= {:start-x 0.1 :start-y 0.5 :end-x 1.1 :end-y 1} + (select-keys (pasted-gradient result) [:start-x :start-y :end-x :end-y])))) + (t/testing "a linear width stays as it was" + (t/is (= 1 (:width (pasted-gradient result))))) + (t/testing "text before the paste keeps its fills" + (t/is (= (span "abc") (-> (spans-of result) first first)))))) + +(t/deftest radial-width-follows-the-aspect-ratio + (let [radial (gradient-fill {:type :radial :start-x 0.5 :start-y 0.5 :end-x 0.5 :end-y 1}) + pasted (content [(span "HELLO" radial)]) + target (content [(span "abc") (span "HELLO" radial)]) + result (text-paste/move-gradients target pasted {:para 0 :offset 3} + source-geometry target-geometry)] + ;; 200x40 is 5:1 and 100x80 is 5:4, so the width grows 4 times. + (t/is (= 4 (:width (pasted-gradient result)))))) + +(t/deftest spans-without-geometry-are-left-alone + (let [red {:fills [{:fill-color "#ff0000" :fill-opacity 1}]} + linear (gradient-fill {:start-x 0 :start-y 0 :end-x 1 :end-y 1}) + pasted (content [(span "red" red) (span "grad" linear)]) + target (content [(span "red" red) (span "grad" linear)])] + (t/testing "a span with no origin keeps its gradient" + (t/is (= target (text-paste/move-gradients + target pasted {:para 0 :offset 0} + {:origins [[nil nil]]} + {:origins [[nil nil]]})))) + (t/testing "an empty box leaves the gradients as they were" + (t/is (= target (text-paste/move-gradients + target pasted {:para 0 :offset 0} + {:origins [[nil {:x 0 :y 0 :box source-box}]]} + {:origins [[nil {:x 0 :y 0 :box {:width 0 :height 80}}]]})))))) + +(t/deftest each-span-moves-from-its-own-box + ;; Copied text shapes are joined into one paste, each span keeping its box. + (let [linear (gradient-fill {:start-x 0.5 :start-y 0 :end-x 0.5 :end-y 1}) + pasted (content [(span "a" linear)] [(span "b" linear)]) + source {:origins [[{:x 0 :y 0 :box {:width 100 :height 20}}] + [{:x 0 :y 0 :box {:width 200 :height 40}}]]} + target {:origins [[{:x 0 :y 0 :box {:width 400 :height 80}}] + [{:x 0 :y 40 :box {:width 400 :height 80}}]]} + result (text-paste/move-gradients pasted pasted {:para 0 :offset 0} source target) + points #(select-keys (-> % :fills first :fill-color-gradient) + [:start-x :start-y :end-x :end-y])] + (t/is (= [{:start-x 0.125 :start-y 0 :end-x 0.125 :end-y 0.25} + {:start-x 0.25 :start-y 0.5 :end-x 0.25 :end-y 1}] + (map (comp points first) (spans-of result)))))) diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index c0bcdbd07f..e5534a1517 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -213,6 +213,15 @@ (:content (clipboard/html->payload (str "" safari "Hello"))))))))) +(t/deftest payload-geometry-round-trip + (let [content (text-content gradient-span) + geometry {:origins [[{:x 12.5 :y 0.2 :box {:width 200 :height 40}}]]} + hidden (clipboard/payload->html content file-id team-id geometry)] + (t/testing "the payload keeps where the copied spans sat in their text box" + (t/is (= geometry (:geometry (clipboard/html->payload hidden))))) + (t/testing "the content stays as copied" + (t/is (= content (:content (clipboard/html->payload hidden))))))) + (t/deftest html-without-payload (t/testing "HTML without a payload, or with a broken one, gives nil" (t/is (nil? (clipboard/html->payload "

Hello

"))) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 7adfa5d692..fdf9adb037 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -3,7 +3,7 @@ use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{BlendMode, Canvas, Color, Paint, Rect}; +use skia_safe::{BlendMode, Canvas, Color, Paint, Point, Rect}; pub fn render_overlay( canvas: &Canvas, @@ -228,10 +228,40 @@ fn calculate_selection_rects( text_content: &TextContent, shape: &Shape, ) -> Vec { - let mut rects = Vec::new(); - let start = selection.start(); let end = selection.end(); + range_rects( + (start.paragraph, start.offset), + (end.paragraph, end.offset), + text_content, + shape, + ) +} + +/// Top-left corner of the first line box of the text between `start` and `end` +/// (paragraph, char offset), shape-local; `None` when the range covers no text. +pub(crate) fn range_origin( + start: (usize, usize), + end: (usize, usize), + text_content: &TextContent, + shape: &Shape, +) -> Option { + range_rects(start, end, text_content, shape) + .first() + .map(|rect| Point::new(rect.left(), rect.top())) +} + +/// Line boxes, shape-local, of the text between `start` and `end` (paragraph, +/// char offset). +fn range_rects( + start: (usize, usize), + end: (usize, usize), + text_content: &TextContent, + shape: &Shape, +) -> Vec { + let mut rects = Vec::new(); + let (start_paragraph, start_offset) = start; + let (end_paragraph, end_offset) = end; let paragraphs = text_content.paragraphs(); let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); @@ -242,7 +272,7 @@ fn calculate_selection_rects( let para_height = laid_out_para.height(); // Check if this paragraph is in selection range - if para_idx < start.paragraph || para_idx > end.paragraph { + if para_idx < start_paragraph || para_idx > end_paragraph { y_offset += para_height; continue; } @@ -255,14 +285,14 @@ fn calculate_selection_rects( .map(|span| span.text.chars().count()) .sum(); - let range_start = if para_idx == start.paragraph { - start.offset + let range_start = if para_idx == start_paragraph { + start_offset } else { 0 }; - let range_end = if para_idx == end.paragraph { - end.offset + let range_end = if para_idx == end_paragraph { + end_offset } else { para_char_count }; @@ -291,3 +321,91 @@ fn calculate_selection_rects( rects } + +#[cfg(test)] +mod tests { + use super::*; + use crate::globals::TestRenderResourcesGuard; + use crate::render::RenderResources; + use crate::shapes::{ + FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextDirection, TextSpan, + }; + use crate::uuid::Uuid; + + /// A laid-out fixed text, `width` wide, with a single span of `text`. + fn text_shape(text: &str, width: f32) -> Shape { + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ); + let mut content = + TextContent::new(Rect::from_xywh(0.0, 0.0, width, 100.0), GrowType::Fixed); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span], + )); + + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(Type::Text(content)); + shape.set_selrect(0.0, 0.0, width, 100.0); + shape + } + + fn origin_of(shape: &Shape, start: usize, end: usize) -> Option { + let Type::Text(content) = &shape.shape_type else { + unreachable!() + }; + range_origin((0, start), (0, end), content, shape) + } + + #[test] + fn range_origin_is_where_the_text_starts() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = text_shape("hello world", 500.0); + + let hello = origin_of(&shape, 0, 5).expect("hello has text"); + let world = origin_of(&shape, 6, 11).expect("world has text"); + + assert!(hello.x.abs() < 0.01, "{hello:?}"); + assert!((0.0..1.0).contains(&hello.y), "{hello:?}"); + assert!(world.x > hello.x, "{world:?}"); + assert!((world.y - hello.y).abs() < 0.01, "{world:?}"); + } + + #[test] + fn range_origin_of_wrapped_text_is_on_its_first_line() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + // Too narrow for both words on one line. + let shape = text_shape("hello world", 45.0); + + let world = origin_of(&shape, 6, 11).expect("world has text"); + + assert!(world.x.abs() < 0.01, "{world:?}"); + assert!(world.y > 10.0, "{world:?}"); + } + + #[test] + fn range_origin_is_none_for_an_empty_range() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = text_shape("hello", 500.0); + + assert!(origin_of(&shape, 2, 2).is_none()); + } +} diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 9a9212ddd2..49b8fc4861 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -424,6 +424,9 @@ pub struct TextContent { /// into cached Skia paragraphs. Kept across move clones so paint can /// translate glyphs + shaders together. See `cached_layout_paint_offset`. layout_paint_origin: Option, + /// Box size the gradient and image shaders were built for. They are relative + /// to the whole box, so a new size must rebuild them. + layout_paint_size: Option<(f32, f32)>, } impl PartialEq for TextContent { @@ -448,6 +451,7 @@ impl TextContent { layout_version: 0, layout_width: None, layout_paint_origin: None, + layout_paint_size: None, } } @@ -464,6 +468,7 @@ impl TextContent { layout_version: 0, layout_width: None, layout_paint_origin: None, + layout_paint_size: None, } } @@ -1114,7 +1119,28 @@ impl TextContent { if self.layout.needs_update() || self.layout_version != self.content_version { return false; } - self.layout_matches_paint_container(shape) + self.layout_matches_paint_container(shape) && self.paint_size_matches(&shape.selrect()) + } + + /// True when some span has a fill laid over the whole box (gradient or image). + fn has_box_relative_fills(&self) -> bool { + self.paragraphs.iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + span.fills + .iter() + .any(|fill| !matches!(fill, shapes::Fill::Solid(_))) + }) + }) + } + + /// True when the cached fill shaders fit a box of `selrect`'s size. + fn paint_size_matches(&self, selrect: &Rect) -> bool { + if !self.has_box_relative_fills() { + return true; + } + self.layout_paint_size.is_some_and(|(width, height)| { + (width - selrect.width()).abs() < 0.01 && (height - selrect.height()).abs() < 0.01 + }) } pub(crate) fn layout_cache_versions_match(&self) -> bool { @@ -1156,11 +1182,13 @@ impl TextContent { // Paragraph paints (incl. absolute image/gradient shaders) were built // against `self.bounds()` in `paragraph_builder_group_from_text`. self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); + self.layout_paint_size = Some((self.bounds.width(), self.bounds.height())); } pub fn force_next_layout_update(&mut self) { self.layout_width = None; self.layout_paint_origin = None; + self.layout_paint_size = None; self.layout.cached_extrect.set(None); // Bump the content version so update_layout can't early-return: auto-width // shapes always match their container and clearing the cache above doesn't @@ -1182,6 +1210,7 @@ impl TextContent { if !self.layout.needs_update() && self.layout_version == self.content_version && layout_matches_container + && self.paint_size_matches(&selrect) { return self.size; } @@ -1351,6 +1380,7 @@ impl Default for TextContent { layout_version: 0, layout_width: None, layout_paint_origin: None, + layout_paint_size: None, } } } @@ -1994,6 +2024,8 @@ pub fn calculate_position_data( #[cfg(test)] mod tests { use super::*; + use crate::globals::TestRenderResourcesGuard; + use crate::render::RenderResources; #[test] fn vertical_align_top_keeps_the_content_at_the_origin() { @@ -2345,6 +2377,69 @@ mod tests { ); } + fn gradient_fill() -> shapes::Fill { + shapes::Fill::LinearGradient(shapes::Gradient::new( + (0.5, 0.0), + (0.5, 1.0), + 255, + 1.0, + &[(skia::Color::RED, 0.0), (skia::Color::BLUE, 1.0)], + )) + } + + fn solid_fill() -> shapes::Fill { + shapes::Fill::Solid(shapes::SolidColor(skia::Color::RED)) + } + + /// A laid-out text shape at 200x100 whose only span has `fills`. + fn laid_out_text_shape(grow_type: GrowType, fills: Vec) -> Shape { + let mut paragraph = test_paragraph(&["hello"]); + paragraph.children_mut()[0].fills = fills; + let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), grow_type); + content.add_paragraph(paragraph); + + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(shapes::Type::Text(content)); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + shape + } + + /// Whether resizing `shape` to `width`x`height`, with no content change, + /// rebuilt its cached Skia paragraphs. + fn resize_rebuilds_paragraphs(shape: &mut Shape, width: f32, height: f32) -> bool { + let before = Rc::clone(&shape.get_text_content().layout.paragraphs); + assert!(!before.is_empty(), "the shape should be laid out"); + shape.set_selrect(0.0, 0.0, width, height); + !Rc::ptr_eq(&before, &shape.get_text_content().layout.paragraphs) + } + + // Gradient and image shaders are built into the cached paragraphs against + // the box size, so a new box size must rebuild them. + + #[test] + fn resizing_a_fixed_text_height_rebuilds_box_relative_fills() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = laid_out_text_shape(GrowType::Fixed, vec![gradient_fill()]); + assert!(resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0)); + } + + #[test] + fn resizing_an_auto_width_text_rebuilds_box_relative_fills() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = laid_out_text_shape(GrowType::AutoWidth, vec![gradient_fill()]); + assert!(resize_rebuilds_paragraphs(&mut shape, 400.0, 300.0)); + } + + #[test] + fn resizing_a_fixed_text_height_keeps_the_cache_for_solid_fills() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = laid_out_text_shape(GrowType::Fixed, vec![solid_fill()]); + assert!(!resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0)); + } + #[test] fn cached_layout_paint_offset_tracks_selrect_move() { let mut content = sample_text_content(); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 43725727f1..9853876249 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -3,6 +3,7 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point}; use crate::mem; +use crate::render::text_editor::range_origin; use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; @@ -1032,3 +1033,56 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { true }) } + +/// Writes where the text between the UTF-16 offsets starts in the text shape +/// (x, y, shape-local) and its box size (width, height); false when no text. +#[no_mangle] +#[wasm_error] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn text_get_range_origin( + a: u32, + b: u32, + c: u32, + d: u32, + start_paragraph: u32, + start_offset: u32, + end_paragraph: u32, + end_offset: u32, + buffer_ptr: *mut f32, +) -> Result { + with_state!(state, { + let Some(shape) = state.shapes.get(&uuid_from_u32_quartet(a, b, c, d)) else { + return Ok(false); + }; + let Type::Text(text_content) = &shape.shape_type else { + return Ok(false); + }; + + let paragraphs = text_content.paragraphs(); + let to_char = |paragraph: u32, offset: u32| { + let paragraph = paragraph as usize; + let offset = paragraphs + .get(paragraph) + .map(|para| para.utf16_offset_to_char(offset as usize)) + .unwrap_or(offset as usize); + (paragraph, offset) + }; + let Some(origin) = range_origin( + to_char(start_paragraph, start_offset), + to_char(end_paragraph, end_offset), + text_content, + shape, + ) else { + return Ok(false); + }; + let selrect = shape.selrect(); + + unsafe { + *buffer_ptr = origin.x; + *buffer_ptr.add(1) = origin.y; + *buffer_ptr.add(2) = selrect.width(); + *buffer_ptr.add(3) = selrect.height(); + } + Ok(true) + }) +} From f8f1a668f7533545ae581f6531678e0c90dbc645 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 22 Oct 2026 15:29:37 +0200 Subject: [PATCH 134/325] :recycle: Use skip-if-active for cron no-overlap in backend worker (#12412) The cron tick kept its own active-job count beside the shared submit option. Delegate the no-overlap check to ::jobs/skip-if-active so both paths share one queue-qualified check served by the partial index. A tick now skips only when a live job sits on the :cron queue, and a skip records both the cron counter and the generic skipped counter. Closes #12411 AI-assisted-by: muse-spark-1.3-contributor --- backend/src/app/worker/cron.clj | 73 ++++++++----------- .../test/backend_tests/worker_cron_test.clj | 48 ++++++++++++ 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/backend/src/app/worker/cron.clj b/backend/src/app/worker/cron.clj index 4e797006dd..0105d9d57e 100644 --- a/backend/src/app/worker/cron.clj +++ b/backend/src/app/worker/cron.clj @@ -12,8 +12,8 @@ detection, retries and telemetry as for every other job. To keep the no-overlap semantics (one entry is not re-fired while the previous instance lives), the scheduler only submits when there is no active - instance (`new`/`scheduled`/`running`/`retry` with the same `name` - and `label` of the entry). The `FOR UPDATE SKIP LOCKED` claim on the + instance (`new`/`scheduled`/`running`/`retry` with the same `name`, + `queue` and `label` of the entry). The `FOR UPDATE SKIP LOCKED` claim on the scheduled_task row already serializes the tick between nodes." (:require [app.common.data :as d] @@ -41,18 +41,6 @@ on conflict (id) do nothing") -;; The tenant filter is what makes the no-overlap check per instance: the -;; job this count decides about is submitted to this tenant's queue, so -;; counting other tenants' active jobs would let one instance skip a tick -;; for work that is none of its business. -(def ^:private sql:count-active-jobs - "SELECT count(*) AS n - FROM job - WHERE name = ? - AND label = ? - AND tenant = ? - AND status IN ('new','scheduled','running','retry')") - (defn- synchronize-cron-entries [{:keys [::db/conn ::entries]}] (doseq [{:keys [id cron]} entries] @@ -70,14 +58,16 @@ (defn submit-cron-job "Submit the system job for the entry to the `:cron` queue. Uses the entry id as the job label (stable per entry) and returns the created - job-id. No-overlap pre-check lives in the caller." + job-id, or nil when an active instance (`new`/`scheduled`/`running`/ + `retry` with the same name, queue and label) already exists." [cfg {:keys [job props id]}] (jobs/submit cfg - {::jobs/name job - ::jobs/params (or props {}) ;; entries don't carry props; default to empty map - ::jobs/queue :cron - ::jobs/label (name id)})) + {::jobs/name job + ::jobs/params (or props {}) ;; entries don't carry props; default to empty map + ::jobs/queue :cron + ::jobs/label (name id) + ::jobs/skip-if-active true})) (declare ^:private schedule-cron-task) @@ -100,29 +90,28 @@ {:id id} {::db/return-keys false}) - ;; The count query is not locked, so a job could - ;; transition from running to completed between the - ;; count and the submit. This is acceptable: the - ;; next tick will submit if needed. The FOR UPDATE - ;; SKIP LOCKED on the scheduled_task row prevents - ;; race conditions between nodes. - (let [tenant (cf/get :tenant) - row (db/exec-one! conn [sql:count-active-jobs (d/name job) (str id) tenant]) - active (or (get row :n) 0)] - (if (pos? active) - (do - (db/after-commit #(metrics/record-cron cfg :skipped :active)) - (l/dbg :hint "skip" - :reason "scheduling, active instance exists" - :id id :job (d/name job))) - (let [job-id (submit-cron-job cfg entry) - elapsed (ct/format-duration (tpoint))] - (db/after-commit #(metrics/record-cron cfg :submitted :none)) - (l/dbg :hint "submit" - :id id - :job (d/name job) - :job-id (str job-id) - :elapsed elapsed))))))) + ;; The no-overlap check lives in the submit itself + ;; (`::jobs/skip-if-active`): the check and the + ;; insert share this transaction, so a job could + ;; still transition from running to completed + ;; between the check and the insert. This is + ;; acceptable: the next tick will submit if + ;; needed. The FOR UPDATE SKIP LOCKED on the + ;; scheduled_task row prevents race conditions + ;; between nodes. + (if-let [job-id (submit-cron-job cfg entry)] + (let [elapsed (ct/format-duration (tpoint))] + (db/after-commit #(metrics/record-cron cfg :submitted :none)) + (l/dbg :hint "submit" + :id id + :job (d/name job) + :job-id (str job-id) + :elapsed elapsed)) + (do + (db/after-commit #(metrics/record-cron cfg :skipped :active)) + (l/dbg :hint "skip" + :reason "scheduling, active instance exists" + :id id :job (d/name job))))))) (catch InterruptedException _ (metrics/record-cron cfg :error :interrupted) diff --git a/backend/test/backend_tests/worker_cron_test.clj b/backend/test/backend_tests/worker_cron_test.clj index d8ab47209a..e0797ab4b0 100644 --- a/backend/test/backend_tests/worker_cron_test.clj +++ b/backend/test/backend_tests/worker_cron_test.clj @@ -199,3 +199,51 @@ (t/testing "and the other tenant's row is untouched" (t/is (= "running" (:status (th/db-get :job {:name "session-gc" :tenant "other-tenant"}))))))) + +(t/deftest cron-tick-is-not-blocked-by-active-job-on-another-queue + (let [cfg (make-cfg) + entry {:id "session-gc-other-queue" + :job :session-gc + :cron (ucron/cron "0 0 0 * * ?")}] + + (th/db-insert! :scheduled-task {:id "session-gc-other-queue" + :cron-expr "0 0 * * *"}) + ;; A live job with the same name, label and tenant, but sitting on + ;; another queue. The cron no-overlap check is qualified by queue, + ;; so it is none of this tick's business. + (th/db-insert! :job {:id (uuid/next) + :name "session-gc" + :tenant (cf/get :tenant) + :queue "default" + :label "session-gc-other-queue" + :status "running" + :params (db/json {}) + :scheduled-at (ct/now) + :created-at (ct/now) + :modified-at (ct/now)}) + + (.join ^Thread (#'cron/execute-cron-task cfg entry)) + + (t/testing "the tick still submits its own job on the cron queue" + (t/is (= 1 (count-jobs cfg :name "session-gc" + :queue "cron" + :label "session-gc-other-queue")))) + (t/testing "and the row on the other queue is untouched" + (t/is (= "running" (:status (th/db-get :job {:name "session-gc" + :queue "default" + :label "session-gc-other-queue"}))))))) + +(t/deftest cron-submit-skips-when-active-instance-on-cron-queue + (let [cfg (make-cfg) + entry (first (get-cron-entries))] + + (let [job-id (cron/submit-cron-job cfg entry)] + (t/is (some? job-id)) + + (t/testing "a second submit while the first is live returns nil" + (t/is (nil? (cron/submit-cron-job cfg entry)))) + + (t/testing "no second row was inserted" + (t/is (= 1 (count-jobs cfg :name "session-gc" + :queue "cron" + :label "session-gc-no-props"))))))) From ba78ccb8c9c82a2aeb2de3e8863ef375584740a7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 22 Oct 2026 15:31:16 +0200 Subject: [PATCH 135/325] :sparkles: Harden file data offload job with lock, summary and metrics (#12403) Take the file row with FOR UPDATE SKIP LOCKED when the offload job starts and skip the run when the row is locked or gone, so the job never runs while the file is being edited. Every live hot-storage row of the file moves to cold storage inside one all-or-nothing transaction, and the handler answers a summary map with the offloaded rows, bytes and per-type stats. Add bounded metrics for the job: rows and bytes counters labeled by row type on successful runs only, and a timing histogram with the ok, skip and error outcomes. The job definition now requires metrics, the recorders live in app.jobs.metrics, and the existing ephemeral job timing histogram pins its buckets. Cover the job with a new test namespace: the happy path, the locked row, missing and deleted files, the rows and bytes summary with its no-op rerun, the whole-file abort on storage failure, the metric definitions and emissions, and the submit-time params validation. An end-to-end suite reads, snapshots, writes, exports, imports and deletes offloaded files; concurrency tests pin the stale-revn skip, the blocked-edit retry and the reclaimable orphans of a partial failure. The file-gc scheduler test pins one job per eligible file, and the scheduler private helper drops its bang. Document the lock invariant, the deliberate no-pacing decision and the metric label sets in the backend storage memory and the configuration guide. Related to #12398. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/backend/storage.md | 5 + backend/src/app/jobs/metrics.clj | 40 ++ backend/src/app/main.clj | 21 +- backend/src/app/tasks/file_gc_scheduler.clj | 4 +- backend/src/app/tasks/offload_file_data.clj | 82 ++- .../backend_tests/file_gc_scheduler_test.clj | 15 + .../backend_tests/offload_file_data_test.clj | 611 ++++++++++++++++++ docs/technical-guide/configuration.md | 2 + 8 files changed, 766 insertions(+), 14 deletions(-) create mode 100644 backend/test/backend_tests/offload_file_data_test.clj diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index 189029aa0f..529fcd1cc8 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -125,6 +125,11 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` - The `file_data.metadata.storage-ref-id` value points to the storage object. - `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row. - File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata. +- Tiered offload (`:tiered-file-data-storage` flag + `db` backend): `file-gc` submits an `:offload-file-data` job (priority 10, delay 1000, same params) only when it actually processed the file; the handler takes the `file` row with `FOR UPDATE SKIP LOCKED` and skips (`{:skipped 1}`, no error) when the row is locked or gone, then moves every live `file_data` row of that file with `backend = 'db'` to `backend = 'storage'` via `fdata/upsert!` (one `put-object!` in bucket `file-data` per row, `data` becomes nil) inside a single all-or-nothing transaction per file. The skip only serializes because every `file_data` writer holds at least `FOR SHARE` on the `file` row. A storage failure aborts only that file and bubbles for a runner retry; orphans go to `storage-gc-touched`. A second run is a no-op because the query only matches `backend = 'db'`. +- Offload pacing: none by deliberate decision. Sleeps only make sense when batching multiple transactions; the per-file transaction is short by construction and the scheduler run takes minutes per production metrics, so neither loop sleeps. `jobs/heartbeat` (pool-based liveness, ~1 write/s) stays on both paths. +- Offload metrics (bounded labels only, never ids or error text): `penpot_file_data_offload_rows_total{type}` and `penpot_file_data_offload_bytes_total{type}` (`type`: `main`/`snapshot`/`fragment`, successful runs only) plus `penpot_file_data_offload_timing_ms{result}` (`result`: `ok`/`skip`/`error`). Helpers live in `app.jobs.metrics`; the job-def requires `::mtx/metrics`. +- Writes always resolve the backend through `fdata/default-backend` (`cf/get :file-data-backend`), so post-offload edits land back in hot (`db`) storage without extra code; reads stay transparent through `fdata/resolve-file-data` + `decode-file-data` + `realize`. +- Deleting an offloaded row touches its storage object first (`objects-gc/delete-file-data-chunk` via `fdata/process-metadata`), so `storage-gc-touched` reclaims the blob with the usual delay; restoring a file unmarks the referenced objects. ## Metrics diff --git a/backend/src/app/jobs/metrics.clj b/backend/src/app/jobs/metrics.clj index 5ba7df2864..ab1941b1b7 100644 --- a/backend/src/app/jobs/metrics.clj +++ b/backend/src/app/jobs/metrics.clj @@ -176,6 +176,46 @@ (record-value metrics :jobs-request-timing [outcome] (max 0 (long millis))))) +(defn- offload-result-label + "Coerce an offload outcome to its label: lowercase, with `failed` + only when there is no outcome at all. Values come from the fixed + call sites of the offload handler (`ok`, `skip`, `error`), never + from params, IDs or exception text." + [result] + (str/lower (mtx/label result "failed"))) + +(defn- offload-type-label + "Coerce a file data type to its label: lowercase, with `other` only + when there is no type at all. Values are the fixed `file_data.type` + values (`main`, `snapshot`, `fragment`)." + [type] + (str/lower (mtx/label type "other"))) + +(defn record-offload-rows + "Count file data rows moved by the offload job, broken down by row + type. Only successful runs emit rows, so there is no result label." + [cfg type amount] + (record-count (mtx/instance cfg) :file-data-offload-rows + [(offload-type-label type)] + amount)) + +(defn record-offload-bytes + "Count file data bytes moved by the offload job, broken down by row + type. Only successful runs emit bytes, so there is no result label." + [cfg type amount] + (record-count (mtx/instance cfg) :file-data-offload-bytes + [(offload-type-label type)] + amount)) + +(defn record-offload-duration + "Time one offload run from lock to summary (milliseconds). Recorded + for every outcome (`ok`, `skip`, `error`), so its count is the run + total per outcome." + [cfg result millis] + (record-value (mtx/instance cfg) :file-data-offload-timing + [(offload-result-label result)] + (max 0 (long millis)))) + (def ^:private backlog-statuses ["new" "scheduled" "running" "retry" "completed" "failed" "cancelled" "aborted"]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 7476f9bfb2..2f119a769f 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -389,7 +389,26 @@ {::mdef/name "penpot_audit_ingest_events_total" ::mdef/help "Audit log frontend events ingested, by kind and outcome." ::mdef/labels ["kind" "outcome"] - ::mdef/type :counter}}) + ::mdef/type :counter} + + :file-data-offload-rows + {::mdef/name "penpot_file_data_offload_rows_total" + ::mdef/help "File data rows moved from hot to cold storage by the offload job." + ::mdef/labels ["type"] + ::mdef/type :counter} + + :file-data-offload-bytes + {::mdef/name "penpot_file_data_offload_bytes_total" + ::mdef/help "File data bytes moved from hot to cold storage by the offload job." + ::mdef/labels ["type"] + ::mdef/type :counter} + + :file-data-offload-timing + {::mdef/name "penpot_file_data_offload_timing_ms" + ::mdef/help "Time spent offloading one file from hot to cold storage (milliseconds)." + ::mdef/labels ["result"] + ::mdef/type :histogram + ::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]}}) (def system-config {::db/pool diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj index 7eaa3801cc..4ac26acf6e 100644 --- a/backend/src/app/tasks/file_gc_scheduler.clj +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -29,7 +29,7 @@ FOR UPDATE OF f SKIP LOCKED") -(defn- schedule! +(defn- schedule-files [{:keys [::db/conn] :as cfg} threshold] (let [total (reduce (fn [total {:keys [id modified-at revn]}] (let [params {:file-id id :revn revn}] @@ -83,4 +83,4 @@ (ct/in-past))] (-> cfg (assoc ::db/rollback (:rollback? params)) - (db/tx-run! schedule! threshold)))) + (db/tx-run! schedule-files threshold)))) diff --git a/backend/src/app/tasks/offload_file_data.clj b/backend/src/app/tasks/offload_file_data.clj index fae8b2d75d..e8e3eb12ff 100644 --- a/backend/src/app/tasks/offload_file_data.clj +++ b/backend/src/app/tasks/offload_file_data.clj @@ -10,9 +10,12 @@ (:require [app.common.logging :as l] [app.common.schema :as sm] + [app.common.time :as ct] [app.db :as db] [app.features.fdata :as fdata] [app.jobs :as jobs] + [app.jobs.metrics :as metrics] + [app.metrics :as mtx] [app.storage :as sto] [integrant.core :as ig])) @@ -23,27 +26,83 @@ AND fd.backend = 'db' AND fd.deleted_at IS NULL") +(def ^:private sql:lock-file + ;; NOTE: SKIP LOCKED only serializes against writers that also lock + ;; the file row. Every file_data writer must therefore hold at least + ;; FOR SHARE on it (the update RPC via lock-for-share?, file-gc via + ;; FOR UPDATE); a writer skipping that lock would reopen the + ;; lost-update race this lock closes. + "SELECT id FROM file WHERE id = ? AND deleted_at IS NULL FOR UPDATE SKIP LOCKED") + (defn- offload-file-data - [cfg {:keys [id file-id type] :as fdata}] + "Offload one hot-storage row to cold storage. Returns the row type + and the migrated bytes for the caller to aggregate." + [cfg {:keys [id file-id type data] :as fdata}] (jobs/heartbeat cfg) (fdata/upsert! cfg (assoc fdata :backend "storage")) - (l/trc :file-id (str file-id) - :id (str id) - :type type)) + (let [size (if (bytes? data) (alength ^bytes data) 0)] + (l/trc :hint "file data row offloaded" + :file-id (str file-id) + :id (str id) + :type type + :bytes size) + {:type type + :size size})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HANDLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- offload-file-rows + "Offload every live hot-storage row of one file inside the caller + transaction. Takes the file row lock first and skips when the row + is locked or gone. Returns a summary map with the offloaded rows, + bytes, per-type stats and the skipped flag." + [{:keys [::db/conn] :as cfg} file-id] + (if (db/exec-one! conn [sql:lock-file file-id]) + (let [{:keys [rows bytes by-type]} + (reduce (fn [stats fdata] + (let [{:keys [type size]} (offload-file-data cfg fdata)] + (-> stats + (update :rows inc) + (update :bytes + size) + (update-in [:by-type type] + (fn [{r :rows b :bytes :or {r 0 b 0}}] + {:rows (inc r) :bytes (+ b size)}))))) + {:rows 0 :bytes 0 :by-type {}} + (db/plan conn [sql:get-file-data file-id]))] + (l/trc :hint "file data offloaded" + :file-id (str file-id) + :offloaded rows + :bytes bytes) + {:offloaded rows :bytes bytes :by-type by-type :skipped 0}) + (do + (l/dbg :hint "skip offload, file is locked or gone" + :file-id (str file-id)) + {:offloaded 0 :bytes 0 :by-type {} :skipped 1}))) + (defn execute-offload-file-data "Plain job handler: offload the file data rows of one file." [cfg params] - (let [file-id (:file-id params)] - (-> cfg - (assoc ::db/rollback (:rollback? params)) - (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] - (run! (partial offload-file-data cfg) - (db/plan conn [sql:get-file-data file-id]))))))) + (let [file-id (:file-id params) + tpoint (ct/tpoint)] + (try + (let [summary (-> cfg + (assoc ::db/rollback (:rollback? params)) + (db/tx-run! offload-file-rows file-id))] + (if (pos? (:skipped summary)) + (do + (metrics/record-offload-duration cfg :skip (inst-ms (tpoint))) + summary) + (do + (doseq [[type {:keys [rows bytes]}] (:by-type summary)] + (metrics/record-offload-rows cfg type rows) + (metrics/record-offload-bytes cfg type bytes)) + (metrics/record-offload-duration cfg :ok (inst-ms (tpoint))) + summary))) + (catch Throwable cause + (metrics/record-offload-duration cfg :error (inst-ms (tpoint))) + (throw cause))))) (def schema:offload-file-data-params [:map @@ -52,7 +111,8 @@ (defmethod ig/assert-key ::job-def [_ params] (assert (db/pool? (::db/pool params)) "expected a valid database pool") - (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided")) + (assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided") + (assert (some? (::mtx/metrics params)) "expected metrics to be provided")) (defmethod ig/init-key ::job-def [_ cfg] diff --git a/backend/test/backend_tests/file_gc_scheduler_test.clj b/backend/test/backend_tests/file_gc_scheduler_test.clj index 0edd217acb..a8702d44ef 100644 --- a/backend/test/backend_tests/file_gc_scheduler_test.clj +++ b/backend/test/backend_tests/file_gc_scheduler_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.file-gc-scheduler-test (:require + [app.common.time :as ct] [app.jobs :as jobs] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -26,3 +27,17 @@ (t/is (thrown-with-msg? Exception #"check error" (jobs/submit cfg {::jobs/name :file-gc-scheduler ::jobs/params {:min-age {:bogus true}}})))))) + +(t/deftest scheduler-submits-one-job-per-eligible-file + (let [profile (th/create-profile* 1)] + (doseq [i [1 2 3]] + (let [file (th/create-file* i {:profile-id (:id profile) + :project-id (:default-project-id profile)})] + ;; Make the files deterministically eligible: pending trim and + ;; modified long enough ago. + (th/db-update! :file {:has-media-trimmed false + :modified-at (ct/in-past "2h")} + {:id (:id file)}))) + (let [result (th/run-task! :file-gc-scheduler {:min-age "1h"})] + (t/is (= 3 (:processed result))) + (t/is (= 3 (count (th/db-query :job {:name "file-gc"}))))))) diff --git a/backend/test/backend_tests/offload_file_data_test.clj b/backend/test/backend_tests/offload_file_data_test.clj new file mode 100644 index 0000000000..f4c99c3310 --- /dev/null +++ b/backend/test/backend_tests/offload_file_data_test.clj @@ -0,0 +1,611 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.offload-file-data-test + (:require + [app.binfile.common :as bfc] + [app.binfile.v3 :as v3] + [app.common.features :as cfeat] + [app.common.time :as ct] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.features.fdata :as fdata] + [app.jobs :as jobs] + [app.jobs.metrics :as metrics] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] + [app.rpc :as-alias rpc] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [app.tasks.offload-file-data :as offload] + [backend-tests.helpers :as th] + [clojure.test :as t] + [datoteka.io :as io] + [integrant.core :as ig]) + (:import + io.prometheus.client.Counter + io.prometheus.client.Counter$Child)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- db-rows + [file-id] + (th/db-query :file-data {:file-id file-id})) + +(defn- create-file + [i profile] + (th/create-file* i {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})) + +(t/deftest offloads-file-data-to-storage + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file))] + + (t/is (pos? (count before))) + (t/is (every? #(= "db" (:backend %)) before)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= (count before) (:offloaded result))) + (t/is (zero? (:skipped result)))) + + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "storage" (:backend %)) after)) + (t/is (every? #(nil? (:data %)) after))))) + +(t/deftest skips-file-locked-by-another-transaction + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file)) + locked (promise) + release (promise) + holder (future + (db/tx-run! th/*system* + (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SELECT id FROM file WHERE id = ? FOR UPDATE" (:id file)]) + (deliver locked true) + @release + :held)))] + + (t/is (= true (deref locked 10000 ::timeout))) + + (try + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result)))) + (finally + (deliver release true) + (deref holder 10000 ::timeout))) + + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "db" (:backend %)) after))))) + +(t/deftest skips-missing-and-deleted-files + (let [result (th/run-task! :offload-file-data {:file-id (uuid/next)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result)))) + + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + _ (th/db-update! :file {:deleted-at (ct/now)} {:id (:id file)}) + before (db-rows (:id file)) + result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result))) + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "db" (:backend %)) after))))) + +(defn- row-bytes + [{:keys [data]}] + (if (bytes? data) + (alength ^bytes data) + 0)) + +(t/deftest summary-reports-rows-and-bytes + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file)) + expected-bytes (reduce + 0 (map row-bytes before))] + + (t/is (pos? (count before))) + (t/is (every? some? (map :data before))) + (t/is (pos? expected-bytes)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= (count before) (:offloaded result))) + (t/is (= expected-bytes (:bytes result))) + (t/is (zero? (:skipped result)))) + + ;; A second run is a no-op: nothing left on the hot storage. + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 0 (:bytes result))) + (t/is (zero? (:skipped result)))))) + +(t/deftest storage-failure-aborts-only-that-file + (let [profile (th/create-profile* 1) + file-a (create-file 1 profile) + file-b (create-file 2 profile) + real-put @#'sto/put-object! + failing (fn [storage params] + (if (= (:file-id params) (:id file-a)) + (throw (ex-info "storage is down" {:file-id (:id file-a)})) + (real-put storage params)))] + + ;; The failing file aborts as a whole: every row stays on hot storage. + (with-redefs [sto/put-object! failing] + (t/is (thrown? Exception + (th/run-task! :offload-file-data {:file-id (:id file-a)})))) + + (let [rows-a (db-rows (:id file-a))] + (t/is (pos? (count rows-a))) + (t/is (every? #(= "db" (:backend %)) rows-a)) + (t/is (every? some? (map :data rows-a)))) + + ;; The other file is independent and offloads while storage is down for A. + (with-redefs [sto/put-object! failing] + (let [result (th/run-task! :offload-file-data {:file-id (:id file-b)})] + (t/is (pos? (:offloaded result))) + (t/is (pos? (:bytes result))) + (t/is (zero? (:skipped result))))) + + (t/is (every? #(= "storage" (:backend %)) (db-rows (:id file-b)))) + + ;; Once storage recovers, a retry converges with no leftovers on hot. + (let [result (th/run-task! :offload-file-data {:file-id (:id file-a)})] + (t/is (pos? (:offloaded result))) + (t/is (pos? (:bytes result))) + (t/is (zero? (:skipped result)))) + + (let [rows-a (db-rows (:id file-a))] + (t/is (every? #(= "storage" (:backend %)) rows-a)) + (t/is (every? #(nil? (:data %)) rows-a))))) + +(defn- make-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics + [:file-data-offload-rows + :file-data-offload-bytes + :file-data-offload-timing])})) + +(defn- offload-cfg + [metrics] + (-> th/*system* + (assoc ::mtx/metrics metrics) + (assoc ::sto/storage (assoc (::sto/storage th/*system*) + ::mtx/metrics metrics)))) + +(defn- counter-value + [metrics id labels] + (let [collector (mtx/get-collector metrics id) + instance (::mdef/instance collector) + child (.labels ^Counter instance (into-array String labels))] + (.get ^Counter$Child child))) + +(def ^:private known-types + ["main" "snapshot" "fragment"]) + +(defn- total-counter + [metrics id] + (reduce + 0 (map #(counter-value metrics id [%]) known-types))) + +(t/deftest metric-definitions-are-pinned + (let [defs main/default-metrics] + (t/is (= "penpot_file_data_offload_rows_total" + (get-in defs [:file-data-offload-rows ::mdef/name]))) + (t/is (= ["type"] + (get-in defs [:file-data-offload-rows ::mdef/labels]))) + (t/is (= :counter + (get-in defs [:file-data-offload-rows ::mdef/type]))) + (t/is (= "penpot_file_data_offload_bytes_total" + (get-in defs [:file-data-offload-bytes ::mdef/name]))) + (t/is (= ["type"] + (get-in defs [:file-data-offload-bytes ::mdef/labels]))) + (t/is (= :counter + (get-in defs [:file-data-offload-bytes ::mdef/type]))) + (t/is (= "penpot_file_data_offload_timing_ms" + (get-in defs [:file-data-offload-timing ::mdef/name]))) + (t/is (= ["result"] + (get-in defs [:file-data-offload-timing ::mdef/labels]))) + (t/is (= :histogram + (get-in defs [:file-data-offload-timing ::mdef/type]))) + (t/is (= [1 10 50 100 500 1000 5000 10000 30000 60000 300000] + (get-in defs [:file-data-offload-timing ::mdef/buckets]))))) + +(t/deftest success-emits-rows-and-bytes + (let [metrics (make-metrics) + profile (th/create-profile* 1) + file (create-file 1 profile) + result (offload/execute-offload-file-data (offload-cfg metrics) + {:file-id (:id file)})] + (t/is (pos? (:offloaded result))) + (t/is (= (double (:offloaded result)) + (total-counter metrics :file-data-offload-rows))) + (t/is (= (double (:bytes result)) + (total-counter metrics :file-data-offload-bytes))) + (t/is (pos? (counter-value metrics :file-data-offload-rows ["main"]))) + (t/is (pos? (total-counter metrics :file-data-offload-rows))))) + +(t/deftest skip-records-skip-and-nothing-else + (let [durations (volatile! []) + rows (volatile! []) + bytes (volatile! [])] + (with-redefs [metrics/record-offload-duration (fn [_ result _] (vswap! durations conj result) nil) + metrics/record-offload-rows (fn [_ type amount] (vswap! rows conj [type amount]) nil) + metrics/record-offload-bytes (fn [_ type amount] (vswap! bytes conj [type amount]) nil)] + (let [result (offload/execute-offload-file-data th/*system* {:file-id (uuid/next)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result))))) + (t/is (= [:skip] @durations)) + (t/is (empty? @rows)) + (t/is (empty? @bytes)))) + +(t/deftest error-records-error-and-rethrows + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + durations (volatile! []) + real-put @#'sto/put-object!] + (with-redefs [sto/put-object! (fn [_ _] (throw (ex-info "storage is down" {}))) + metrics/record-offload-duration (fn [_ result _] (vswap! durations conj result) nil)] + (t/is (thrown? Exception + (offload/execute-offload-file-data th/*system* {:file-id (:id file)})))) + (t/is (= [:error] @durations)) + ;; Nothing was migrated: the whole file aborted. + (t/is (every? #(= "db" (:backend %)) (db-rows (:id file)))))) + +(t/deftest missing-metrics-raises + (let [params {::db/pool th/*pool* + ::sto/storage (::sto/storage th/*system*)}] + (t/is (thrown? AssertionError + (ig/assert-key :app.tasks.offload-file-data/job-def params))) + (t/is (nil? (ig/assert-key :app.tasks.offload-file-data/job-def + (assoc params ::mtx/metrics (:app.metrics/metrics th/*system*))))))) + +(t/deftest submit-validates-params-upfront + (let [cfg th/*system*] + (t/testing "valid params submit a durable job" + (t/is (uuid? (jobs/submit cfg {::jobs/name :offload-file-data + ::jobs/params {:file-id (uuid/next)}})))) + (t/testing "invalid params fail at submit instead of burning retries" + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :offload-file-data + ::jobs/params {}}))) + (t/is (thrown? Exception + (jobs/submit cfg {::jobs/name :offload-file-data + ::jobs/params {:file-id "not-a-uuid"}})))))) + +(t/deftest file-gc-without-flag-does-not-enqueue-offload + (t/is (not (contains? cf/flags :tiered-file-data-storage))) + (let [profile (th/create-profile* 1) + file (create-file 1 profile)] + (t/is (true? (th/run-task! :file-gc {:file-id (:id file)}))) + (th/run-pending-jobs) + (t/testing "no offload job was enqueued" + (t/is (empty? (th/db-query :job {:name "offload-file-data"})))) + (t/testing "every row stays on hot storage" + (let [rows (db-rows (:id file))] + (t/is (pos? (count rows))) + (t/is (every? #(= "db" (:backend %)) rows)) + (t/is (every? some? (map :data rows))))))) + +(defn- update-file! + [& {:keys [profile-id file-id changes revn] :or {revn 0}}] + (let [params {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :vern 0 + :features cfeat/supported-features + :changes changes} + out (th/command! params)] + (t/is (nil? (:error out))) + (:result out))) + +(defn- add-page-with-rect + [profile file page-id shape-id] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + [{:type :add-page + :name "test" + :id page-id} + {:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect})}])) + +(defn- get-file-data + [profile file-id] + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id file-id})] + (t/is (nil? (:error out))) + (get-in out [:result :data]))) + +(t/deftest offloaded-file-reads-identically + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + (add-page-with-rect profile file page-id shape-id) + + (let [before-bfc (:data (bfc/get-file th/*system* (:id file) {:realize? true})) + before-rpc (get-file-data profile (:id file))] + + (t/is (some? (get-in before-bfc [:pages-index page-id :objects shape-id]))) + (t/is (= before-bfc before-rpc)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result))) + (t/is (every? #(= "storage" (:backend %)) (remove :deleted-at (db-rows (:id file)))))) + + (t/testing "direct read resolves cold storage transparently" + (t/is (= before-bfc + (:data (bfc/get-file th/*system* (:id file) {:realize? true}))))) + + (t/testing "rpc read resolves cold storage transparently" + (t/is (= before-rpc (get-file-data profile (:id file)))))))) + +(t/deftest snapshot-survives-offload + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + (add-page-with-rect profile file page-id shape-id) + + (let [out (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :label "before-offload"})] + (t/is (nil? (:error out))) + (t/is (uuid? (get-in out [:result :id])))) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result)))) + + (t/testing "live snapshot rows moved to cold storage" + (let [snapshots (th/db-query :file-data {:file-id (:id file) + :type "snapshot" + :deleted-at nil})] + (t/is (pos? (count snapshots))) + (t/is (every? #(= "storage" (:backend %)) snapshots)) + (t/is (every? #(nil? (:data %)) snapshots)))) + + (t/testing "snapshots are still listed" + (let [out (th/command! {::th/type :get-file-snapshots + ::rpc/profile-id (:id profile) + :file-id (:id file)})] + (t/is (nil? (:error out))) + (t/is (some #(= "before-offload" (:label %)) (:result out))))) + + (t/testing "snapshot restores from cold storage" + (let [snap-id (->> (:result (th/command! {::th/type :get-file-snapshots + ::rpc/profile-id (:id profile) + :file-id (:id file)})) + (filter #(= "before-offload" (:label %))) + first + :id) + out (th/command! {::th/type :restore-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :id snap-id})] + (t/is (some? snap-id)) + (t/is (nil? (:error out))) + (t/is (some? (get-in (get-file-data profile (:id file)) + [:pages-index page-id :objects shape-id]))))))) + +(t/deftest write-after-offload-returns-to-hot-storage + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result)))) + + (let [main (th/db-get :file-data {:file-id (:id file) :type "main"})] + (t/is (= "storage" (:backend main))) + (t/is (nil? (:data main)))) + + (add-page-with-rect profile file page-id shape-id) + + (t/testing "the new main row is back on hot storage" + (let [main (th/db-get :file-data {:file-id (:id file) :type "main"})] + (t/is (= "db" (:backend main))) + (t/is (bytes? (:data main))))) + + (t/testing "the written content reads back" + (t/is (some? (get-in (get-file-data profile (:id file)) + [:pages-index page-id :objects shape-id])))))) + +(t/deftest export-import-roundtrip-after-offload + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next) + shape-id (uuid/next)] + (add-page-with-rect profile file page-id shape-id) + + (let [before (:data (bfc/get-file th/*system* (:id file) {:realize? true})) + _ (th/run-task! :offload-file-data {:file-id (:id file)}) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (:data (bfc/get-file th/*system* (first (:file-ids result)) + {:realize? true}))] + (t/is (= 1 (count (:file-ids result)))) + (t/is (= (set (keys (:pages-index before))) + (set (keys (:pages-index imported))))) + (t/is (= (get-in before [:pages-index page-id :name]) + (get-in imported [:pages-index page-id :name]))) + (t/is (some? (get-in imported [:pages-index page-id :objects shape-id]))))))) + +(t/deftest deleted-offloaded-file-is-fully-purged + (let [storage (sto/resolve th/*system*) + profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-id (uuid/next)] + + (add-page-with-rect profile file page-id (uuid/next)) + + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result)))) + + (let [ref-ids (into #{} + (comp (map :metadata) + (map fdata/decode-metadata) + (keep :storage-ref-id)) + (db-rows (:id file)))] + (t/is (pos? (count ref-ids))) + (t/is (every? some? (map #(sto/get-object storage %) ref-ids))) + + (let [out (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (nil? (:error out)))) + (th/run-pending-jobs) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (th/run-task! :objects-gc {}))] + (t/is (pos? (:processed res)))) + + (t/testing "every file data row is gone" + (t/is (empty? (db-rows (:id file))))) + + ;; The touches above landed 8 days in the future, so the touched + ;; collector needs a clock past them plus its deletion delay. + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 9}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (pos? (:delete res)))) + + (t/testing "every referenced storage object is reclaimed" + (let [objects (mapv #(sto/get-object storage %) ref-ids)] + (t/is (every? nil? objects))))))) + +(t/deftest file-gc-with-stale-revn-enqueues-nothing + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + before (db-rows (:id file)) + stale (inc (or (:revn file) 0))] + (t/is (not (th/run-task! :file-gc {:file-id (:id file) :revn stale}))) + (th/run-pending-jobs) + (t/testing "no offload job was enqueued" + (t/is (empty? (th/db-query :job {:name "offload-file-data"})))) + (t/testing "every row stays on hot storage" + (let [after (db-rows (:id file))] + (t/is (= (count before) (count after))) + (t/is (every? #(= "db" (:backend %)) after)))))) + +(t/deftest concurrent-edit-and-offload-stay-coherent + (let [profile (th/create-profile* 1 {:is-active true}) + file (create-file 1 profile) + page-1 (uuid/next) + shape-1 (uuid/next) + page-2 (uuid/next) + shape-2 (uuid/next)] + (add-page-with-rect profile file page-1 shape-1) + + (let [locked (promise) + release (promise) + holder (future + (db/tx-run! th/*system* + (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SELECT id FROM file WHERE id = ? FOR UPDATE" (:id file)]) + (deliver locked true) + @release + :held))) + _ (t/is (= true (deref locked 10000 ::timeout))) + updater (future + (add-page-with-rect profile file page-2 shape-2))] + + (try + ;; Give the updater time to block on the held row lock. + (th/sleep 500) + (t/is (not (realized? updater))) + + (t/testing "offload skips while the row is locked" + (let [result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (= 0 (:offloaded result))) + (t/is (= 1 (:skipped result))))) + (finally + (deliver release true))) + + (t/is (not= ::timeout (deref holder 10000 ::timeout))) + (t/is (not= ::timeout (deref updater 30000 ::timeout)))) + + (t/testing "the blocked edit won as a whole" + (t/is (some? (get-in (get-file-data profile (:id file)) + [:pages-index page-2 :objects shape-2])))) + + (t/testing "a later offload migrates the new bytes, never stale ones" + (let [current (get-file-data profile (:id file)) + result (th/run-task! :offload-file-data {:file-id (:id file)})] + (t/is (pos? (:offloaded result))) + (t/is (= current (get-file-data profile (:id file)))))))) + +(t/deftest partial-failure-leaves-reclaimable-orphans + (let [profile (th/create-profile* 1) + file (create-file 1 profile) + _ (t/is (>= (count (db-rows (:id file))) 2)) + calls (volatile! 0) + orphans (volatile! []) + real-put @#'sto/put-object! + mock (fn [storage params] + (if (= (:file-id params) (:id file)) + (let [n (vswap! calls inc)] + (if (= 1 n) + (let [object (real-put storage params)] + (vswap! orphans conj (:id object)) + object) + (throw (ex-info "storage is down" {})))) + (real-put storage params)))] + + (with-redefs [sto/put-object! mock] + (t/is (thrown? Exception + (th/run-task! :offload-file-data {:file-id (:id file)})))) + + (t/testing "the aborted file keeps every row on hot storage" + (let [rows (db-rows (:id file))] + (t/is (every? #(= "db" (:backend %)) rows)) + (t/is (every? some? (map :data rows))))) + + (t/testing "the orphaned blob is reclaimed once old enough" + (t/is (= 1 (count @orphans))) + (let [storage (sto/resolve th/*system*)] + (t/is (some? (sto/get-object storage (first @orphans)))) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (pos? (:delete res)))) + (t/is (nil? (sto/get-object storage (first @orphans)))))))) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index b239d53551..aa4593fa61 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -828,6 +828,8 @@ PENPOT_FLAGS: [...] enable-tiered-file-data-storage PENPOT_FILE_DATA_BACKEND: db ``` +Once enabled, the offload job moves one file per run inside a single transaction: it locks the file row (skipping the run while the file is being edited) and migrates every live data row to the objects storage. You can follow it with `penpot_file_data_offload_rows_total` and `penpot_file_data_offload_bytes_total` (label `type="main|snapshot|fragment"`) and `penpot_file_data_offload_timing_ms` (label `result="ok|skip|error"`). + ### Autosave By default, Penpot stores manually saved versions indefinitely; these can be found in the History tab and can be renamed, restored, deleted, etc. Additionally, the default behavior of on-premise instances is to not keep automatic version history. This automatic behavior can be modified and adapted to each on-premise installation with the corresponding configuration. From 9aa78a536b35e8c7832e99d760b3aed024ffc162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Thu, 22 Oct 2026 17:32:52 +0200 Subject: [PATCH 136/325] :sparkles: Resolve tokens with the tokenscript-interpreter package (#12348) Replace the @penpot/tokenscript npm package (the TypeScript interpreter) with penpot/tokenscript-interpreter, a Clojure and ClojureScript interpreter that now lives in its own repository and compiles into the frontend build. The frontend adapter (app.main.data.tokenscript) resolves tokens with it behind the existing :tokenscript flag and maps its results to the values and errors the workspace uses: - Token values are validated by the interpreter's token types, passed by their DTCG names: `1foo` is not a dimension, a number has no unit, an opacity is between 0 and 1. Penpot only adds its own limits (font weight names, text decoration keywords). - Division by zero, incompatible units, unknown functions and function arguments out of range get messages of their own, and token pills show the message of their error. - Font family names are quoted before resolving, as in the adapter this replaces (#11750): `Red Hat` stays one name. The token form's value input passed the token name to the tokenscript resolver in place of the typed value, and no name at all for a new token. It now resolves the typed value under the form's token name, as the StyleDictionary path does. StyleDictionary stays the default until the interpreter is stable. frontend/deps.edn pins the interpreter by tag and commit, v0.1.0. Signed-off-by: Florian Schroedl --- common/src/app/common/types/token.cljc | 8 + frontend/deps.edn | 5 + frontend/package.json | 1 - frontend/packages/tokenscript/.gitignore | 3 - frontend/packages/tokenscript/index.js | 2 - frontend/packages/tokenscript/package.json | 13 - frontend/packages/tokenscript/schemas.js | 1631 ----------------- frontend/pnpm-lock.yaml | 68 - .../src/app/main/data/style_dictionary.cljs | 9 +- frontend/src/app/main/data/tokenscript.cljs | 484 +++-- .../data/workspace/tokens/application.cljs | 3 - .../app/main/data/workspace/tokens/color.cljs | 11 +- .../main/data/workspace/tokens/errors.cljs | 16 + .../main/data/workspace/tokens/format.cljs | 36 +- .../data/workspace/tokens/propagation.cljs | 5 +- .../forms/controls/color_input.cljs | 5 +- .../management/forms/controls/combobox.cljs | 5 +- .../forms/controls/fonts_combobox.cljs | 5 +- .../management/forms/controls/input.cljs | 32 +- .../tokens/management/forms/validators.cljs | 5 +- .../tokens/management/token_pill.cljs | 26 +- frontend/src/app/plugins/tokens.cljs | 41 +- frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/tokenscript_test.cljs | 184 ++ frontend/translations/en.po | 16 + frontend/translations/es.po | 16 + 26 files changed, 619 insertions(+), 2013 deletions(-) delete mode 100644 frontend/packages/tokenscript/.gitignore delete mode 100644 frontend/packages/tokenscript/index.js delete mode 100644 frontend/packages/tokenscript/package.json delete mode 100644 frontend/packages/tokenscript/schemas.js create mode 100644 frontend/test/frontend_tests/tokens/tokenscript_test.cljs diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index fd7a96e55f..704fc55083 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -664,6 +664,14 @@ (when (contains? text-decoration-values normalized-value) normalized-value))) +(def text-case-values #{"none" "uppercase" "lowercase" "capitalize"}) + +(defn number-out-of-bounds? + "Whether a resolved numeric token value is outside the safe integer + range." + [n] + (or (>= n sm/max-safe-int) (<= n sm/min-safe-int))) + (def font-weight-aliases {"100" #{"thin" "hairline"}, "200" #{"ultra light" "extralight" "extraleicht" "extra-light" "ultra-light" "ultralight" "extra light"}, diff --git a/frontend/deps.edn b/frontend/deps.edn index c5184e43ae..7e19542857 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -30,6 +30,11 @@ :git/sha "d726717" :git/url "https://github.com/funcool/rumext.git"} + penpot/tokenscript-interpreter + {:git/tag "v0.1.0" + :git/sha "224547d" + :git/url "https://github.com/penpot/tokenscript-interpreter.git"} + instaparse/instaparse {:mvn/version "1.5.0"} garden/garden {:git/url "https://github.com/noprompt/garden" :git/sha "05590ecb5f6fa670856f3d1ab400aa4961047480"} diff --git a/frontend/package.json b/frontend/package.json index e470580a87..f8adeafd03 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,6 @@ "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", "@penpot/svgo": "penpot/svgo#3.3.0", "@penpot/text-editor": "link:text-editor", - "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.63.0", "@storybook/addon-docs": "10.6.1", diff --git a/frontend/packages/tokenscript/.gitignore b/frontend/packages/tokenscript/.gitignore deleted file mode 100644 index 0db2a5c1d2..0000000000 --- a/frontend/packages/tokenscript/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -*.sublime-workspace -/.yarn \ No newline at end of file diff --git a/frontend/packages/tokenscript/index.js b/frontend/packages/tokenscript/index.js deleted file mode 100644 index 4d7deb9d72..0000000000 --- a/frontend/packages/tokenscript/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./schemas.js"; -export * from "@tokens-studio/tokenscript-interpreter"; diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json deleted file mode 100644 index 8cafca6749..0000000000 --- a/frontend/packages/tokenscript/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@penpot/tokenscript", - "version": "1.0.0", - "description": "", - "main": "index.js", - "type": "module", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", - "author": "Andrey Antukh", - "license": "MPL-2.0", - "dependencies": { - "@tokens-studio/tokenscript-interpreter": "^0.26.0" - } -} diff --git a/frontend/packages/tokenscript/schemas.js b/frontend/packages/tokenscript/schemas.js deleted file mode 100644 index 75ead35ce7..0000000000 --- a/frontend/packages/tokenscript/schemas.js +++ /dev/null @@ -1,1631 +0,0 @@ -// Auto-generated by @tokens-studio/tokenscript-schemas -// Version: @tokens-studio/tokenscript-schemas@v0.4.0 -// GitHub: https://github.com/tokens-studio/tokenscript-schemas -// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css preset:cssColors --output ./schemas.js -// Generated: 2026-02-11T08:46:40.467Z - -import { Config } from "@tokens-studio/tokenscript-interpreter"; - -export const SCHEMAS = [ - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - schema: { - "name": "Hex", - "type": "color", - "description": "A color in hex format, e.g. #ff0000", - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - } - }, - "initializers": [ - { - "title": "Hex Color Initializer", - "keyword": "hex", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "variable c: Color.Hex;\nc.value = {input};\nreturn c;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB (0-1) to Hex format", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to Hex Conversion\n// Converts sRGB (0-1) to hexadecimal string format\n//\n// Examples:\n// sRGB(1, 0, 0) → #ff0000\n// sRGB(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel\nvalue = round({input}.r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = round({input}.g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = round({input}.b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - "target": "$self", - "description": "Converts Display P3 to Hex format (clamps to sRGB gamut)", - "lossless": false, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display P3 to Hex Conversion\n// Converts P3 (0-1) to hexadecimal string format\n// Note: P3 colors may be out of sRGB gamut, values are clamped to 0-1\n//\n// Examples:\n// P3(1, 0, 0) → #ff0000\n// P3(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel (clamp P3 to sRGB range)\nvalue = {input}.r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = {input}.g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = {input}.b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to Hex format", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to Hex Conversion\n// Converts HSL to hexadecimal string format\n// Reference: Standard HSL to RGB algorithm\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Hex string #rrggbb\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// RGB values (default to achromatic)\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation\nif (s > 0) [\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n\n variable p: Number = 2 * l - q;\n\n // Red (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n\n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n\n // Green (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n\n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n\n // Blue (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n\n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Convert RGB to hex\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red\nvalue = round(r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = round(g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = round(b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to Hex format (clamps to sRGB gamut)", - "lossless": false, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to Hex Conversion\n// Converts OKLCH perceptual color to hexadecimal string format\n// Path: OKLCH → OKLab → XYZ-D65 → Linear sRGB → sRGB → Hex\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360)\n// Output: Hex string #rrggbb\n\n// Get input OKLCH values\nvariable ok_l: Number = {input}.l;\nvariable ok_c: Number = {input}.c;\nvariable ok_h: Number = {input}.h;\n\n// === Step 1: OKLCH to OKLab (polar to cartesian) ===\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\nvariable h_rad: Number = ok_h * deg_to_rad;\n\nvariable lab_a: Number = ok_c * cos(h_rad);\nvariable lab_b: Number = ok_c * sin(h_rad);\n\n// === Step 2: OKLab to XYZ-D65 ===\n// Inverse Lab-to-LMS matrix\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * lab_a + 0.2158037573099136 * lab_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * lab_a + -0.0638541728258133 * lab_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * lab_a + -1.2914855480194092 * lab_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse LMS-to-XYZ matrix\nvariable xyz_x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable xyz_y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable xyz_z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// === Step 3: XYZ-D65 to Linear sRGB ===\nvariable linear_r: Number = 3.2409699419045226 * xyz_x + -1.537383177570094 * xyz_y + -0.4986107602930034 * xyz_z;\nvariable linear_g: Number = -0.9692436362808796 * xyz_x + 1.8759675015077202 * xyz_y + 0.04155505740717559 * xyz_z;\nvariable linear_b: Number = 0.05563007969699366 * xyz_x + -0.20397695888897652 * xyz_y + 1.0569715142428786 * xyz_z;\n\n// === Step 4: Linear sRGB to sRGB (gamma correction) ===\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exp: Number = 0.416666666666667;\n\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n if (linear_r > 0) [\n srgb_r = gamma_scale * pow(linear_r, gamma_exp) - gamma_offset;\n ] else [\n srgb_r = 0;\n ];\n];\n\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n if (linear_g > 0) [\n srgb_g = gamma_scale * pow(linear_g, gamma_exp) - gamma_offset;\n ] else [\n srgb_g = 0;\n ];\n];\n\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n if (linear_b > 0) [\n srgb_b = gamma_scale * pow(linear_b, gamma_exp) - gamma_offset;\n ] else [\n srgb_b = 0;\n ];\n];\n\n// === Step 5: sRGB to Hex ===\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red (clamp to 0-1)\nvalue = srgb_r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = srgb_g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = srgb_b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - schema: { - "name": "SRGB", - "type": "color", - "description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Red channel (0-1)" - }, - "g": { - "type": "number", - "description": "Green channel (0-1)" - }, - "b": { - "type": "number", - "description": "Blue channel (0-1)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "sRGB Color Initializer", - "keyword": "srgb", - "description": "Creates an sRGB color from normalized 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - "target": "$self", - "description": "Converts RGB (0-255) to sRGB (0-1) by normalizing", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to sRGB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to sRGB by applying gamma correction", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - schema: { - "name": "Rgb", - "type": "color", - "description": "RGB color", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number" - }, - "g": { - "type": "number" - }, - "b": { - "type": "number" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "function", - "keyword": "rgb", - "description": "Creates an RGB color", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB Color Initializer\n// Creates an RGB color from r, g, b values (0-255 range)\n//\n// Usage: rgb(255, 128, 64) → Color.Rgb { r: 255, g: 128, b: 64 }\n// rgb(255, 128, 64, 0.5) → Color.Rgb { r: 255, g: 128, b: 64, alpha: 0.5 }\n//\n// Input: List of 3 or 4 numbers [r, g, b] or [r, g, b, alpha]\n// Output: Color.Rgb\n\nvariable color_parts: List = {input}; \n\nvariable output: Color.Rgb;\noutput.r = color_parts.get(0);\noutput.g = color_parts.get(1);\noutput.b = color_parts.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_parts.length() > 3) [\n output.alpha = color_parts.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "function", - "keyword": "rgba", - "description": "Creates an RGB color with alpha", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGBA Color Initializer\n// Creates an RGB color with alpha from r, g, b, a values\n//\n// Usage: rgba(255, 128, 64, 0.5) → Color.Rgb { r: 255, g: 128, b: 64, alpha: 0.5 }\n//\n// Input: List of 4 numbers [r, g, b, alpha]\n// Output: Color.Rgb with alpha\n\nvariable color_parts: List = {input}; \n\nvariable output: Color.Rgb;\noutput.r = color_parts.get(0);\noutput.g = color_parts.get(1);\noutput.b = color_parts.get(2);\noutput.alpha = color_parts.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - "target": "$self", - "description": "Converts HEX to RGB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Hex to RGB Conversion\n// Converts hexadecimal color string to RGB (0-255 range)\n//\n// Supports both shorthand (#RGB) and standard (#RRGGBB) formats:\n// #f00 → RGB(255, 0, 0)\n// #ff0000 → RGB(255, 0, 0)\n//\n// Algorithm:\n// 1. Remove '#' prefix and split into characters\n// 2. For shorthand: duplicate each digit (f → ff)\n// 3. Parse hex pairs to decimal values\n//\n// Input: Color.Hex (e.g., #ff5733)\n// Output: Color.Rgb with r, g, b in 0-255 range\n\n// Split hex string on '#' and get the color value\nvariable color_parts: List = {input}.to_string().split('#'); \nvariable color: List = color_parts.get(1).split(); \nvariable length: Number = color.length(); \n\n// Initialize RGB values\nvariable rgb: List = 0, 0, 0; \n\n// Handle shorthand (#RGB) vs standard (#RRGGBB) format\nif(length == 3) [ \n // Shorthand: duplicate each digit\n rgb.update(0, parse_int(color.get(0).concat(color.get(0)), 16)); \n rgb.update(1, parse_int(color.get(1).concat(color.get(1)), 16)); \n rgb.update(2, parse_int(color.get(2).concat(color.get(2)), 16)); \n] else [ \n // Standard: pair adjacent digits\n rgb.update(0, parse_int(color.get(0).concat(color.get(1)), 16)); \n rgb.update(1, parse_int(color.get(2).concat(color.get(3)), 16)); \n rgb.update(2, parse_int(color.get(4).concat(color.get(5)), 16)); \n]; \n\n// Create output color\nvariable output: Color.Rgb; \noutput.r = rgb.get(0); \noutput.g = rgb.get(1); \noutput.b = rgb.get(2); \n\nreturn output;" - } - }, - { - "source": "$self", - "target": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - "description": "Converts RGB to HEX", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to Hex Conversion\n// Converts RGB (0-255) color to hexadecimal string format\n//\n// Examples:\n// RGB(255, 0, 0) → #ff0000\n// RGB(0, 255, 128) → #00ff80\n//\n// Algorithm:\n// 1. Round each channel to nearest integer\n// 2. Convert to base-16 string\n// 3. Pad single digits with leading zero\n// 4. Concatenate with '#' prefix\n//\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: String (hex format #rrggbb)\n\nvariable rgba: List = {input}.r, {input}.g, {input}.b;\nvariable hex: String = \"#\";\nvariable i: Number = 0;\nvariable value: Number = 0;\n\n// Convert each RGB channel to hex\nwhile( i < min(rgba.length(), 3)) [\n value = round(rgba.get(i));\n if(value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n ] else [\n hex = hex.concat(value.to_string(16));\n ];\n i = i + 1;\n];\n\nif (rgba.length() == 4) [\n value = rgba.get(3) * 255; // Convert alpha to 0-255 range\n if(value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n ] else [\n hex = hex.concat(value.to_string(16));\n ];\n];\n\nreturn hex;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - schema: { - "name": "HSL", - "type": "color", - "description": "HSL color space - Hue, Saturation, Lightness. Popular for color pickers and CSS.", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "s": { - "type": "number", - "description": "Saturation (0-1)" - }, - "l": { - "type": "number", - "description": "Lightness (0-1)" - } - }, - "required": [ - "h", - "s", - "l" - ], - "order": [ - "h", - "s", - "l" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HSL Color Initializer", - "keyword": "hsl", - "description": "Creates an HSL color from H, S, L values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL Color Initializer\n// Creates an HSL color from H, S, L values\n// Input: List of [h, s, l] or [h, s, l, alpha] values\n\nvariable hsl_values: List = {input};\nvariable output: Color.HSL;\n\noutput.h = hsl_values.get(0);\noutput.s = hsl_values.get(1);\noutput.l = hsl_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hsl_values.length() > 3) [\n output.alpha = hsl_values.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "HSLA Color Initializer", - "keyword": "hsla", - "description": "Creates an HSL color with alpha from H, S, L, A values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSLA Color Initializer\n// Creates an HSL color with alpha from H, S, L, A values\n//\n// Usage: hsla(180, 0.5, 0.5, 0.8) → Color.HSL { h: 180, s: 0.5, l: 0.5, alpha: 0.8 }\n//\n// Input: List of 4 numbers [h, s, l, alpha]\n// Output: Color.HSL with alpha\n\nvariable hsl_values: List = {input};\nvariable output: Color.HSL;\n\noutput.h = hsl_values.get(0);\noutput.s = hsl_values.get(1);\noutput.l = hsl_values.get(2);\noutput.alpha = hsl_values.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to HSL", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to HSL Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. Find max and min of R, G, B\n// 2. L = (max + min) / 2\n// 3. If max == min, S = 0 (achromatic)\n// 4. Else S = (max - min) / (1 - |2L - 1|)\n// 5. H depends on which channel is max\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.HSL with h (0-360), s (0-1), l (0-1)\n\n// Get input sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Find max and min\nvariable max_val: Number = r;\nif (g > max_val) [\n max_val = g;\n];\nif (b > max_val) [\n max_val = b;\n];\n\nvariable min_val: Number = r;\nif (g < min_val) [\n min_val = g;\n];\nif (b < min_val) [\n min_val = b;\n];\n\n// Calculate lightness\nvariable l: Number = (max_val + min_val) / 2;\n\n// Calculate saturation and hue\nvariable s: Number = 0;\nvariable h: Number = 0;\nvariable delta: Number = max_val - min_val;\n\nif (delta > 0) [\n // Not achromatic\n \n // Saturation formula\n variable abs_2l_minus_1: Number = 2 * l - 1;\n if (abs_2l_minus_1 < 0) [\n abs_2l_minus_1 = 0 - abs_2l_minus_1;\n ];\n s = delta / (1 - abs_2l_minus_1);\n \n // Hue calculation depends on which channel is max\n if (max_val == r) [\n h = ((g - b) / delta);\n if (g < b) [\n h = h + 6;\n ];\n ] else [\n if (max_val == g) [\n h = (b - r) / delta + 2;\n ] else [\n h = (r - g) / delta + 4;\n ];\n ];\n \n // Convert to degrees\n h = h * 60;\n];\n\n// Normalize hue to 0-360\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.HSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - schema: { - "name": "LinearSRGB", - "type": "color", - "description": "Linear sRGB color space (gamma-decoded). Used for matrix transformations to XYZ and other linear operations.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Linear red channel (0-1)" - }, - "g": { - "type": "number", - "description": "Linear green channel (0-1)" - }, - "b": { - "type": "number", - "description": "Linear blue channel (0-1)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Linear sRGB Color Initializer", - "keyword": "linearsrgb", - "description": "Creates a linear sRGB color from linear 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB Color Initializer\n// Creates a linear sRGB color from linear 0-1 values\n// Input: List of [r, g, b] linear values\n\nvariable color_values: List = {input};\nvariable output: Color.LinearSRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to Linear sRGB by removing gamma correction (IEC 61966-2-1)", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to Linear sRGB Conversion\n// Removes gamma correction (inverse transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if srgb ≤ 0.04045: linear = srgb / 12.92\n// else: linear = ((srgb + 0.055) / 1.055) ^ 2.4\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.LinearSRGB with r, g, b in linear 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.04045;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 2.4;\n\n// Get input sRGB values\nvariable srgb_r: Number = {input}.r;\nvariable srgb_g: Number = {input}.g;\nvariable srgb_b: Number = {input}.b;\n\n// Convert red channel\nvariable linear_r: Number = 0;\nif (srgb_r <= threshold) [\n linear_r = srgb_r / linear_scale;\n] else [\n linear_r = pow((srgb_r + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Convert green channel\nvariable linear_g: Number = 0;\nif (srgb_g <= threshold) [\n linear_g = srgb_g / linear_scale;\n] else [\n linear_g = pow((srgb_g + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Convert blue channel\nvariable linear_b: Number = 0;\nif (srgb_b <= threshold) [\n linear_b = srgb_b / linear_scale;\n] else [\n linear_b = pow((srgb_b + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Create output\nvariable output: Color.LinearSRGB;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to Linear sRGB using inverse transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to Linear sRGB Conversion\n// Reference: ColorJS exact matrix values (extracted via unit vector conversion)\n//\n// Matrix (XYZ D65 to Linear sRGB):\n// [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ]\n// [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559]\n// [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786]\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values\n// Output: Color.LinearSRGB with r, g, b in linear 0-1 range\n\n// Get input XYZ values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// Apply inverse matrix transformation (ColorJS exact values)\n// Row 1: R\nvariable linear_r: Number = 3.2409699419045226 * x + -1.537383177570094 * y + -0.4986107602930034 * z;\n\n// Row 2: G\nvariable linear_g: Number = -0.9692436362808796 * x + 1.8759675015077202 * y + 0.04155505740717559 * z;\n\n// Row 3: B\nvariable linear_b: Number = 0.05563007969699366 * x + -0.20397695888897652 * y + 1.0569715142428786 * z;\n\n// Create output\nvariable output: Color.LinearSRGB;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - schema: { - "name": "XYZD65", - "type": "color", - "description": "CIE XYZ color space with D65 white point. The primary connection hub for color space conversions.", - "schema": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "X tristimulus value" - }, - "y": { - "type": "number", - "description": "Y tristimulus value (luminance)" - }, - "z": { - "type": "number", - "description": "Z tristimulus value" - } - }, - "required": [ - "x", - "y", - "z" - ], - "order": [ - "x", - "y", - "z" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "XYZ-D65 Color Initializer", - "keyword": "xyzd65", - "description": "Creates an XYZ-D65 color from tristimulus values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 Color Initializer\n// Creates an XYZ-D65 color from tristimulus values\n// Input: List of [x, y, z] tristimulus values\n\nvariable xyz_values: List = {input};\nvariable output: Color.XYZD65;\n\noutput.x = xyz_values.get(0);\noutput.y = xyz_values.get(1);\noutput.z = xyz_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to XYZ-D65 using the sRGB transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to XYZ-D65 Conversion\n// Uses the sRGB primaries transformation matrix\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Matrix (Linear RGB → XYZ-D65):\n// [X] [0.4123908 0.3575843 0.1804808] [R]\n// [Y] = [0.2126390 0.7151687 0.0721923] [G]\n// [Z] [0.0193308 0.1191948 0.9505322] [B]\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get input linear sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Matrix multiplication: M × [R, G, B]ᵀ\n// Row 1: X = 0.4123908 * R + 0.3575843 * G + 0.1804808 * B\nvariable x: Number = r * 0.41239079926595934 + g * 0.357584339383878 + b * 0.1804807884018343;\n\n// Row 2: Y = 0.2126390 * R + 0.7151687 * G + 0.0721923 * B\nvariable y: Number = r * 0.21263900587151027 + g * 0.715168678767756 + b * 0.07219231536073371;\n\n// Row 3: Z = 0.0193308 * R + 0.1191948 * G + 0.9505322 * B\nvariable z: Number = r * 0.01933081871559182 + g * 0.11919477979462598 + b * 0.9505321522496607;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - "target": "$self", - "description": "Converts Linear P3 to XYZ-D65 using the P3 transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 to XYZ-D65 Conversion\n// Converts linear Display-P3 RGB to CIE XYZ (D65)\n// Reference: CSS Color Level 4 specification\n//\n// Uses the P3 to XYZ-D65 transformation matrix\n// Matrix values from ColorJS / CSS Color Level 4\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get linear P3 values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Linear P3 to XYZ-D65 matrix\n// Row 1\nvariable m00: Number = 0.4865709486482162;\nvariable m01: Number = 0.26566769316909306;\nvariable m02: Number = 0.1982172852343625;\n\n// Row 2\nvariable m10: Number = 0.2289745640697488;\nvariable m11: Number = 0.6917385218365064;\nvariable m12: Number = 0.079286914093745;\n\n// Row 3\nvariable m20: Number = 0.0;\nvariable m21: Number = 0.04511338185890264;\nvariable m22: Number = 1.043944368900976;\n\n// Matrix multiplication: [x, y, z] = M × [r, g, b]\nvariable x: Number = m00 * r + m01 * g + m02 * b;\nvariable y: Number = m10 * r + m11 * g + m12 * b;\nvariable z: Number = m20 * r + m21 * g + m22 * b;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to XYZ-D65 using inverse LMS transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to XYZ-D65 Conversion\n// Reference: ColorJS oklab.js (inverse transformation)\n//\n// Algorithm:\n// 1. Apply inverse Lab-to-LMS matrix to get LMS cone responses\n// 2. Cube the LMS values (inverse of cube root)\n// 3. Apply inverse LMS-to-XYZ matrix\n//\n// Input: Color.OKLab with l, a, b perceptual coordinates\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get input OKLab values\nvariable ok_l: Number = {input}.l;\nvariable ok_a: Number = {input}.a;\nvariable ok_b: Number = {input}.b;\n\n// Inverse LMStoLab_M matrix (Lab to LMS')\n// These are the inverse of the matrix used in from-xyz-d65.tokenscript\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * ok_a + 0.2158037573099136 * ok_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * ok_a + -0.0638541728258133 * ok_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * ok_a + -1.2914855480194092 * ok_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse XYZtoLMS_M matrix (LMS to XYZ)\n// From ColorJS oklab.js\nvariable x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - schema: { - "name": "LinearP3", - "type": "color", - "description": "Linear Display-P3 color space (gamma-decoded). Used for matrix transformations to XYZ.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Linear red channel (0-1, can exceed for HDR)" - }, - "g": { - "type": "number", - "description": "Linear green channel (0-1, can exceed for HDR)" - }, - "b": { - "type": "number", - "description": "Linear blue channel (0-1, can exceed for HDR)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Linear P3 Color Initializer", - "keyword": "linearp3", - "description": "Creates a linear Display-P3 color from linear 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 Color Initializer\n// Creates a linear Display-P3 color from linear 0-1 values\n// Input: List of [r, g, b] linear values\n\nvariable color_values: List = {input};\nvariable output: Color.LinearP3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to Linear P3 using the P3 transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to Linear P3 Conversion\n// Converts CIE XYZ (D65) to linear Display-P3 RGB\n// Reference: CSS Color Level 4 specification\n//\n// Uses the inverse of the P3 to XYZ-D65 matrix\n// Matrix values from ColorJS / CSS Color Level 4\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values\n// Output: Color.LinearP3 with linear r, g, b values (may be outside 0-1 for out-of-gamut)\n\n// Get XYZ values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// XYZ to Linear P3 matrix (inverse of P3 to XYZ)\n// Row 1\nvariable m00: Number = 2.4934969119414254;\nvariable m01: Number = -0.9313836179191239;\nvariable m02: Number = -0.40271078445071684;\n\n// Row 2\nvariable m10: Number = -0.8294889695615747;\nvariable m11: Number = 1.7626640603183463;\nvariable m12: Number = 0.023624685841943577;\n\n// Row 3\nvariable m20: Number = 0.03584583024378447;\nvariable m21: Number = -0.07617238926804182;\nvariable m22: Number = 0.9568845240076872;\n\n// Matrix multiplication: [r, g, b] = M × [x, y, z]\nvariable linear_r: Number = m00 * x + m01 * y + m02 * z;\nvariable linear_g: Number = m10 * x + m11 * y + m12 * z;\nvariable linear_b: Number = m20 * x + m21 * y + m22 * z;\n\n// Create output (note: values may be outside 0-1 for out-of-gamut colors)\nvariable output: Color.LinearP3;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - schema: { - "name": "OKLab", - "type": "color", - "description": "OKLab perceptually uniform color space by Björn Ottosson. L is lightness (0-1), a is green-red axis, b is blue-yellow axis.", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-1)" - }, - "a": { - "type": "number", - "description": "Green-red axis (typically -0.4 to 0.4)" - }, - "b": { - "type": "number", - "description": "Blue-yellow axis (typically -0.4 to 0.4)" - } - }, - "required": [ - "l", - "a", - "b" - ], - "order": [ - "l", - "a", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKLab Color Initializer", - "keyword": "oklab", - "description": "Creates an OKLab color from L, a, b values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab Color Initializer\n// Creates an OKLab color from L, a, b values\n// Input: List of [l, a, b] or [l, a, b, alpha] values\n\nvariable lab_values: List = {input};\nvariable output: Color.OKLab;\n\noutput.l = lab_values.get(0);\noutput.a = lab_values.get(1);\noutput.b = lab_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lab_values.length() > 3) [\n output.alpha = lab_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to OKLab using Björn Ottosson's algorithm", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to OKLab Conversion\n// Uses ColorJS-compatible matrices (recalculated for consistent D65 white)\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/oklab.js\n//\n// Algorithm:\n// 1. XYZ-D65 → LMS (M1 matrix)\n// 2. LMS → LMS' (cube root transformation)\n// 3. LMS' → OKLab (M2 matrix)\n//\n// Input: Color.XYZD65 with x, y, z tristimulus\n// Output: Color.OKLab with l, a, b coordinates\n\n// Get input XYZ-D65 values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// Step 1: XYZ-D65 → LMS cone response\n// ColorJS XYZtoLMS_M matrix (recalculated for consistent reference white)\nvariable lms_l: Number = x * 0.8190224379967030 + y * 0.3619062600528904 + z * -0.1288737815209879;\nvariable lms_m: Number = x * 0.0329836539323885 + y * 0.9292868615863434 + z * 0.0361446663506424;\nvariable lms_s: Number = x * 0.0481771893596242 + y * 0.2642395317527308 + z * 0.6335478284694309;\n\n// Step 2: Apply cube root (γ = 1/3)\n// Note: Using pow with 1/3 exponent (cbrt equivalent for positive values)\nvariable cube_root_exp: Number = 0.3333333333333333;\nvariable lms_l_prime: Number = pow(lms_l, cube_root_exp);\nvariable lms_m_prime: Number = pow(lms_m, cube_root_exp);\nvariable lms_s_prime: Number = pow(lms_s, cube_root_exp);\n\n// Step 3: LMS' → OKLab\n// ColorJS LMStoLab_M matrix\nvariable oklab_l: Number = lms_l_prime * 0.2104542683093140 + lms_m_prime * 0.7936177747023054 + lms_s_prime * -0.0040720430116193;\nvariable oklab_a: Number = lms_l_prime * 1.9779985324311684 + lms_m_prime * -2.4285922420485799 + lms_s_prime * 0.4505937096174110;\nvariable oklab_b: Number = lms_l_prime * 0.0259040424655478 + lms_m_prime * 0.7827717124575296 + lms_s_prime * -0.8086757549230774;\n\n// Create output\nvariable output: Color.OKLab;\noutput.l = oklab_l;\noutput.a = oklab_a;\noutput.b = oklab_b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to OKLab (polar to cartesian)", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to OKLab Conversion\n// Converts polar (C, H) back to cartesian (a, b) coordinates\n// Reference: Björn Ottosson's OKLab specification\n//\n// Algorithm:\n// L stays the same (lightness)\n// a = C * cos(H * π/180)\n// b = C * sin(H * π/180)\n//\n// Input: Color.OKLCH with l, c, h coordinates\n// Output: Color.OKLab with l, a, b coordinates\n\n// Get input OKLCH values\nvariable l: Number = {input}.l;\nvariable c: Number = {input}.c;\nvariable h: Number = {input}.h;\n\n// Constants\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\n\n// Convert hue to radians\nvariable h_rad: Number = h * deg_to_rad;\n\n// Convert polar to cartesian\nvariable a: Number = c * cos(h_rad);\nvariable b: Number = c * sin(h_rad);\n\n// Create output\nvariable output: Color.OKLab;\noutput.l = l;\noutput.a = a;\noutput.b = b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsl-color/0/", - "target": "$self", - "description": "Converts OKHSL to OKLab", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSL to OKLab Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js\n//\n// Converts OKHSL back to OKLab using:\n// 1. Inverse toe function to recover OKLab lightness\n// 2. Cusp finding to determine max chroma at this hue\n// 3. Saturation denormalization to get actual chroma\n//\n// Input: Color.OKHSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.OKLab with l (0-1), a, b coordinates\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable l: Number = input.l;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// Toe function constants\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Apply inverse toe function to get OKLab lightness\n// toe_inv(x) = (x^2 + k1*x) / (k3 * (x + k2))\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_l: Number = l;\nif (l > 0.0001 && l < 0.9999) [\n lab_l = (l * l + toe_k1 * l) / (toe_k3 * (l + toe_k2));\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Handle achromatic case\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_a: Number = 0;\nvariable lab_b: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Compute hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable h_rad: Number = h * pi_val / 180;\nvariable a_: Number = cos(h_rad);\nvariable b_: Number = sin(h_rad);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in steps 4 and 5)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find maximum saturation at this hue (same as forward conversion)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\n\n// Variables for LMS intermediate calculations (reused in step 5)\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (s >= 0.0001) [\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Find cusp\n// ═══════════════════════════════════════════════════════════════════════════\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\nif (s >= 0.0001 && s_max > 0) [\n // Recompute LMS values with refined s_max (kl, km, ks already computed)\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 6: Compute chroma from saturation\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = 0;\n\nif (s >= 0.0001 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n variable s_t_cusp: Number = c_cusp / l_cusp;\n \n // Denormalize saturation to get chroma\n c = s * lab_l * s_t_cusp;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 7: Convert polar to cartesian\n// ═══════════════════════════════════════════════════════════════════════════\nif (s >= 0.0001) [\n lab_a = c * a_;\n lab_b = c * b_;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKLab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\noutput" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsv-color/0/", - "target": "$self", - "description": "Converts OKHSV to OKLab", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSV to OKLab Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsv.js\n//\n// Converts OKHSV back to OKLab using:\n// 1. Cusp finding to determine gamut boundary at this hue\n// 2. V and S denormalization to get L and C\n//\n// Input: Color.OKHSV with h (0-360), s (0-1), v (0-1)\n// Output: Color.OKLab with l (0-1), a, b coordinates\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable v: Number = input.v;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Handle edge cases\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_l: Number = v;\nvariable lab_a: Number = 0;\nvariable lab_b: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Compute hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable h_rad: Number = h * pi_val / 180;\nvariable a_: Number = cos(h_rad);\nvariable b_: Number = sin(h_rad);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in steps 3 and 4)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Find maximum saturation at this hue\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\n\n// Variables for LMS intermediate calculations (reused in step 4)\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001) [\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find cusp\n// ═══════════════════════════════════════════════════════════════════════════\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001 && s_max > 0) [\n // Recompute LMS values with refined s_max (kl, km, ks already computed)\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Compute L and C from V and S\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n variable s_t_cusp: Number = c_cusp / l_cusp;\n variable t_t_cusp: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Compute max chroma at this V\n variable c_max_at_v: Number = 0;\n \n if (v <= l_cusp) [\n c_max_at_v = v * s_t_cusp;\n ] else [\n c_max_at_v = (1 - v) * t_t_cusp;\n ];\n \n // Compute actual chroma\n c = s * c_max_at_v;\n \n // Compute L from V and C\n // Reverse of: V = L + C / s_t_cusp\n lab_l = v - c / (s_t_cusp + 0.0001);\n if (lab_l < 0) [ lab_l = 0; ];\n if (lab_l > 1) [ lab_l = 1; ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 6: Convert polar to cartesian\n// ═══════════════════════════════════════════════════════════════════════════\nif (s >= 0.0001 && v >= 0.0001) [\n lab_a = c * a_;\n lab_b = c * b_;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKLab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - schema: { - "name": "OKLCH", - "type": "color", - "description": "OKLCH color space - the polar form of OKLab. L is lightness (0-1), C is chroma, H is hue angle (0-360).", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-1)" - }, - "c": { - "type": "number", - "description": "Chroma (0 to ~0.4 for sRGB gamut)" - }, - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - } - }, - "required": [ - "l", - "c", - "h" - ], - "order": [ - "l", - "c", - "h" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKLCH Color Initializer", - "keyword": "oklch", - "description": "Creates an OKLCH color from L, C, H values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH Color Initializer\n// Creates an OKLCH color from L, C, H values\n// Input: List of [l, c, h] or [l, c, h, alpha] values\n\nvariable lch_values: List = {input};\nvariable output: Color.OKLCH;\n\noutput.l = lch_values.get(0);\noutput.c = lch_values.get(1);\noutput.h = lch_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lch_values.length() > 3) [\n output.alpha = lch_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKLCH using Cartesian to polar transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKLCH Conversion\n// Converts Cartesian (a, b) to polar (C, H) coordinates\n// Reference: Björn Ottosson's OKLab specification\n//\n// Algorithm:\n// L stays the same (lightness)\n// C = sqrt(a² + b²) (chroma - distance from neutral axis)\n// H = atan2(b, a) * 180/π (hue angle in degrees)\n//\n// Input: Color.OKLab with l, a, b coordinates\n// Output: Color.OKLCH with l, c, h coordinates\n\n// Get input OKLab values\nvariable l: Number = {input}.l;\nvariable a: Number = {input}.a;\nvariable b: Number = {input}.b;\n\n// Constants\nvariable pi: Number = pi();\nvariable rad_to_deg: Number = 180 / pi;\n\n// Calculate chroma (distance from neutral axis)\nvariable c: Number = sqrt(a * a + b * b);\n\n// Calculate hue angle using atan2\n// atan2(y, x) returns angle in radians from -π to π\nvariable h_rad: Number = atan2(b, a);\nvariable h: Number = h_rad * rad_to_deg;\n\n// Normalize hue to 0-360 range\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.OKLCH;\noutput.l = l;\noutput.c = c;\noutput.h = h;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsl-color/0/", - schema: { - "name": "OKHSL", - "type": "color", - "description": "OKHSL color space by Björn Ottosson - a perceptually uniform HSL based on OKLab. H is hue (0-360), S is saturation (0-1, normalized to sRGB gamut), L is lightness (0-1). Reference: https://bottosson.github.io/posts/colorpicker/", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees), same as OKLCH hue" - }, - "s": { - "type": "number", - "description": "Saturation (0-1), normalized relative to sRGB gamut boundary" - }, - "l": { - "type": "number", - "description": "Lightness (0-1), perceptually uniform with toe function applied" - } - }, - "required": [ - "h", - "s", - "l" - ], - "order": [ - "h", - "s", - "l" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKHSL Color Initializer", - "keyword": "okhsl", - "description": "Creates an OKHSL color from H, S, L values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSL Color Initializer\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n//\n// Creates an OKHSL color from hue, saturation, and lightness values.\n// OKHSL is a perceptually uniform version of HSL built on OKLab.\n//\n// Parameters:\n// - h: Hue angle (0-360 degrees), same as OKLCH hue\n// - s: Saturation (0-1), normalized to sRGB gamut boundary\n// - l: Lightness (0-1), perceptually uniform with toe function\n// - alpha: Optional alpha channel (0-1)\n//\n// Input: Object with h, s, l, and optional alpha properties\n// Output: Color.OKHSL\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable l: Number = input.l;\n\nvariable output: Color.OKHSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\n\n// Set alpha if provided\nif (input.alpha != null) [\n output.alpha = input.alpha;\n];\n\noutput" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKHSL using Ottosson's algorithm with Halley's method refinement for gamut boundary", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKHSL Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js\n// Reference: https://bottosson.github.io/posts/oklab/ (original OKLab paper)\n//\n// OKHSL provides a perceptually uniform HSL with proper saturation scaling.\n// The algorithm uses three key chroma values (C_0, C_mid, C_max) and\n// performs piecewise interpolation to map chroma to saturation.\n//\n// This implementation includes the FULL findGamutIntersection algorithm\n// with Halley's method refinement for accurate gamut boundary detection.\n//\n// Input: Color.OKLab with l (0-1), a, b coordinates\n// Output: Color.OKHSL with h (0-360), s (0-1), l (0-1)\n\nvariable lab_l: Number = input.l;\nvariable lab_a: Number = input.a;\nvariable lab_b: Number = input.b;\n\n// Native constants\nvariable pi_val: Number = pi();\nvariable float_max: Number = 999999999;\n\n// Toe function constants\n// K3 = (1 + K1) / (1 + K2)\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// LMS to OKLab matrix coefficients (LabtoLMS_M columns 1,2)\nvariable lab_lms_kl_a: Number = 0.3963377774;\nvariable lab_lms_kl_b: Number = 0.2158037573;\nvariable lab_lms_km_a: Number = -0.1055613458;\nvariable lab_lms_km_b: Number = -0.0638541728;\nvariable lab_lms_ks_a: Number = -0.0894841775;\nvariable lab_lms_ks_b: Number = -1.2914855480;\n\n// LMS to sRGB-linear matrix\nvariable lms_r0: Number = 4.0767416360759583;\nvariable lms_r1: Number = -3.3077115392580629;\nvariable lms_r2: Number = 0.2309699031821043;\nvariable lms_g0: Number = -1.2684379732850315;\nvariable lms_g1: Number = 2.6097573492876882;\nvariable lms_g2: Number = -0.3413193760026570;\nvariable lms_b0: Number = -0.0041960761386756;\nvariable lms_b1: Number = -0.7034186179359362;\nvariable lms_b2: Number = 1.7076146940746117;\n\n// RGB limit coefficients for determining which channel clips first\nvariable red_limit_a: Number = -1.8817031;\nvariable red_limit_b: Number = -0.80936501;\nvariable green_limit_a: Number = 1.8144408;\nvariable green_limit_b: Number = -1.19445267;\n\n// Red Kn coefficients\nvariable red_k0: Number = 1.19086277;\nvariable red_k1: Number = 1.76576728;\nvariable red_k2: Number = 0.59662641;\nvariable red_k3: Number = 0.75515197;\nvariable red_k4: Number = 0.56771245;\n\n// Green Kn coefficients\nvariable green_k0: Number = 0.73956515;\nvariable green_k1: Number = -0.45954404;\nvariable green_k2: Number = 0.08285427;\nvariable green_k3: Number = 0.12541073;\nvariable green_k4: Number = -0.14503204;\n\n// Blue Kn coefficients\nvariable blue_k0: Number = 1.35733652;\nvariable blue_k1: Number = -0.00915799;\nvariable blue_k2: Number = -1.1513021;\nvariable blue_k3: Number = -0.50559606;\nvariable blue_k4: Number = 0.00692167;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Convert to polar coordinates and apply toe function\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = sqrt(lab_a * lab_a + lab_b * lab_b);\n\n// Apply toe function: toe(x) = 0.5 * (k3*x - k1 + sqrt((k3*x - k1)^2 + 4*k2*k3*x))\nvariable l: Number = lab_l;\nif (lab_l > 0.0001 && lab_l < 0.9999) [\n variable term: Number = toe_k3 * lab_l - toe_k1;\n l = 0.5 * (term + sqrt(term * term + 4 * toe_k2 * toe_k3 * lab_l));\n];\n\n// Compute hue using atan2(-b, -a) + 0.5 (ColorJS convention)\nvariable h: Number = 0;\nif (c > 0.00001) [\n variable h_normalized: Number = 0.5 + atan2(-lab_b, -lab_a) / (2 * pi_val);\n h = h_normalized * 360;\n if (h < 0) [ h = h + 360; ];\n if (h >= 360) [ h = h - 360; ];\n];\n\n// Initialize saturation\nvariable s: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Check if chromatic (non-achromatic, non-edge)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable is_chromatic: Number = 1;\nif (c < 0.00001) [ is_chromatic = 0; ];\nif (l < 0.0001) [ is_chromatic = 0; ];\nif (l > 0.9999) [ is_chromatic = 0; ];\n\nif (is_chromatic > 0.5) [\n // ═══════════════════════════════════════════════════════════════════════\n // Step 3: Normalize hue direction (a_, b_)\n // ═══════════════════════════════════════════════════════════════════════\n variable a_: Number = lab_a / c;\n variable b_: Number = lab_b / c;\n \n // Pre-compute LMS coefficients\n variable kl: Number = lab_lms_kl_a * a_ + lab_lms_kl_b * b_;\n variable km: Number = lab_lms_km_a * a_ + lab_lms_km_b * b_;\n variable ks: Number = lab_lms_ks_a * a_ + lab_lms_ks_b * b_;\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 4: computeMaxSaturation - Find S_max for this hue\n // ═══════════════════════════════════════════════════════════════════════\n variable test_red: Number = red_limit_a * a_ + red_limit_b * b_;\n variable test_green: Number = green_limit_a * a_ + green_limit_b * b_;\n \n variable k0: Number = blue_k0;\n variable k1_coef: Number = blue_k1;\n variable k2_coef: Number = blue_k2;\n variable k3_coef: Number = blue_k3;\n variable k4_coef: Number = blue_k4;\n variable wl: Number = lms_b0;\n variable wm: Number = lms_b1;\n variable ws: Number = lms_b2;\n \n if (test_red > 1) [\n k0 = red_k0; k1_coef = red_k1; k2_coef = red_k2; k3_coef = red_k3; k4_coef = red_k4;\n wl = lms_r0; wm = lms_r1; ws = lms_r2;\n ] else [\n if (test_green > 1) [\n k0 = green_k0; k1_coef = green_k1; k2_coef = green_k2; k3_coef = green_k3; k4_coef = green_k4;\n wl = lms_g0; wm = lms_g1; ws = lms_g2;\n ];\n ];\n \n // Polynomial approximation\n variable s_max: Number = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement for s_max\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable halley_denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(halley_denom) > 0.000001) [\n s_max = s_max - f * f1 / halley_denom;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 5: findCusp - Get L_cusp and C_cusp\n // ═══════════════════════════════════════════════════════════════════════\n variable l_cusp: Number = 1;\n variable c_cusp: Number = 0;\n \n if (s_max > 0) [\n variable cusp_l_: Number = 1 + s_max * kl;\n variable cusp_m_: Number = 1 + s_max * km;\n variable cusp_s_: Number = 1 + s_max * ks;\n \n variable cusp_l: Number = cusp_l_ * cusp_l_ * cusp_l_;\n variable cusp_m: Number = cusp_m_ * cusp_m_ * cusp_m_;\n variable cusp_s: Number = cusp_s_ * cusp_s_ * cusp_s_;\n \n variable cusp_r: Number = lms_r0 * cusp_l + lms_r1 * cusp_m + lms_r2 * cusp_s;\n variable cusp_g: Number = lms_g0 * cusp_l + lms_g1 * cusp_m + lms_g2 * cusp_s;\n variable cusp_b: Number = lms_b0 * cusp_l + lms_b1 * cusp_m + lms_b2 * cusp_s;\n \n variable max_rgb: Number = cusp_r;\n if (cusp_g > max_rgb) [ max_rgb = cusp_g; ];\n if (cusp_b > max_rgb) [ max_rgb = cusp_b; ];\n if (max_rgb < 0.0001) [ max_rgb = 0.0001; ];\n \n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 6: findGamutIntersection - Find C_max at current L\n // This is the FULL algorithm with Halley's method for upper half\n //\n // Finds intersection of line: L = L0*(1-t) + t*L1, C = t*C1\n // where L0=L1=lab_l and C1=1 (seeking max chroma at this L)\n // ═══════════════════════════════════════════════════════════════════════\n variable c_max: Number = 0;\n \n if (l_cusp > 0.0001 && c_cusp > 0.0001) [\n // Parameters for gamut intersection: l1=lab_l, c1=1, l0=lab_l\n variable l1: Number = lab_l;\n variable c1: Number = 1;\n variable l0: Number = lab_l;\n \n // Check which half: (l1 - l0) * c_cusp - (l_cusp - l0) * c1\n // Since l1 = l0 = lab_l, this simplifies to: -(l_cusp - lab_l) * 1 = lab_l - l_cusp\n variable half_test: Number = lab_l - l_cusp;\n variable t_intersect: Number = 0;\n \n if (half_test <= 0) [\n // Lower half (below cusp) - simple triangle intersection\n // t = (c_cusp * l0) / (c1 * l_cusp + c_cusp * (l0 - l1))\n // Since l0 = l1, denominator = c1 * l_cusp = l_cusp\n variable lower_denom: Number = l_cusp;\n if (abs(lower_denom) > 0.00001) [\n t_intersect = (c_cusp * lab_l) / lower_denom;\n ];\n ] else [\n // Upper half (above cusp) - triangle + Halley's method\n // First: triangle intersection\n // t = (c_cusp * (l0 - 1)) / (c1 * (l_cusp - 1) + c_cusp * (l0 - l1))\n // Since l0 = l1 = lab_l: t = (c_cusp * (lab_l - 1)) / (l_cusp - 1)\n variable upper_denom: Number = l_cusp - 1;\n if (abs(upper_denom) > 0.00001) [\n t_intersect = c_cusp * (lab_l - 1) / upper_denom;\n ];\n \n // Halley's method refinement for upper half\n // dl = l1 - l0 = 0, dc = c1 = 1\n variable dl: Number = 0;\n variable dc: Number = 1;\n \n variable ldt_: Number = dl + dc * kl;\n variable mdt_: Number = dl + dc * km;\n variable sdt_: Number = dl + dc * ks;\n \n // Compute L and C at current t\n variable L_at_t: Number = l0 * (1 - t_intersect) + t_intersect * l1;\n variable C_at_t: Number = t_intersect * c1;\n \n // LMS values at (L, C)\n variable l_at: Number = L_at_t + C_at_t * kl;\n variable m_at: Number = L_at_t + C_at_t * km;\n variable s_at: Number = L_at_t + C_at_t * ks;\n \n variable l_lms: Number = l_at * l_at * l_at;\n variable m_lms: Number = m_at * m_at * m_at;\n variable s_lms: Number = s_at * s_at * s_at;\n \n // First derivatives\n variable ldt: Number = 3 * ldt_ * l_at * l_at;\n variable mdt: Number = 3 * mdt_ * m_at * m_at;\n variable sdt: Number = 3 * sdt_ * s_at * s_at;\n \n // Second derivatives\n variable ldt2: Number = 6 * ldt_ * ldt_ * l_at;\n variable mdt2: Number = 6 * mdt_ * mdt_ * m_at;\n variable sdt2: Number = 6 * sdt_ * sdt_ * s_at;\n \n // Red channel Halley step\n variable r_val: Number = lms_r0 * l_lms + lms_r1 * m_lms + lms_r2 * s_lms - 1;\n variable r1: Number = lms_r0 * ldt + lms_r1 * mdt + lms_r2 * sdt;\n variable r2: Number = lms_r0 * ldt2 + lms_r1 * mdt2 + lms_r2 * sdt2;\n variable r_denom: Number = r1 * r1 - 0.5 * r_val * r2;\n variable ur: Number = 0;\n variable tr: Number = float_max;\n if (abs(r_denom) > 0.000001) [\n ur = r1 / r_denom;\n if (ur >= 0) [ tr = -r_val * ur; ];\n ];\n \n // Green channel Halley step\n variable g_val: Number = lms_g0 * l_lms + lms_g1 * m_lms + lms_g2 * s_lms - 1;\n variable g1: Number = lms_g0 * ldt + lms_g1 * mdt + lms_g2 * sdt;\n variable g2: Number = lms_g0 * ldt2 + lms_g1 * mdt2 + lms_g2 * sdt2;\n variable g_denom: Number = g1 * g1 - 0.5 * g_val * g2;\n variable ug: Number = 0;\n variable tg: Number = float_max;\n if (abs(g_denom) > 0.000001) [\n ug = g1 / g_denom;\n if (ug >= 0) [ tg = -g_val * ug; ];\n ];\n \n // Blue channel Halley step\n variable b_val: Number = lms_b0 * l_lms + lms_b1 * m_lms + lms_b2 * s_lms - 1;\n variable b1: Number = lms_b0 * ldt + lms_b1 * mdt + lms_b2 * sdt;\n variable b2: Number = lms_b0 * ldt2 + lms_b1 * mdt2 + lms_b2 * sdt2;\n variable b_denom: Number = b1 * b1 - 0.5 * b_val * b2;\n variable ub: Number = 0;\n variable tb: Number = float_max;\n if (abs(b_denom) > 0.000001) [\n ub = b1 / b_denom;\n if (ub >= 0) [ tb = -b_val * ub; ];\n ];\n \n // Take minimum of the three corrections\n variable t_correction: Number = tr;\n if (tg < t_correction) [ t_correction = tg; ];\n if (tb < t_correction) [ t_correction = tb; ];\n if (t_correction < float_max) [\n t_intersect = t_intersect + t_correction;\n ];\n ];\n \n // C_max = t * c1 = t (since c1 = 1)\n c_max = t_intersect;\n if (c_max < 0) [ c_max = 0; ];\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 7: getStMid - Polynomial approximation for mid-saturation ST\n // ═══════════════════════════════════════════════════════════════════════\n variable st_mid_s: Number = 0.11516993 + 1 / (\n 7.44778970 + 4.15901240 * b_ +\n a_ * (-2.19557347 + 1.75198401 * b_ +\n a_ * (-2.13704948 - 10.02301043 * b_ +\n a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_))));\n \n variable st_mid_t: Number = 0.11239642 + 1 / (\n 1.61320320 - 0.68124379 * b_ +\n a_ * (0.40370612 + 0.90148123 * b_ +\n a_ * (-0.27087943 + 0.61223990 * b_ +\n a_ * (0.00299215 - 0.45399568 * b_ - 0.14661872 * a_))));\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 8: getCs - Compute C_0, C_mid, C_max with scale factor k\n // ═══════════════════════════════════════════════════════════════════════\n variable st_max_s: Number = c_cusp / (l_cusp + 0.0001);\n variable st_max_t: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Scale factor k = c_max / min(L * st_max_s, (1-L) * st_max_t)\n variable min_st: Number = lab_l * st_max_s;\n variable min_st_t: Number = (1 - lab_l) * st_max_t;\n if (min_st_t < min_st) [ min_st = min_st_t; ];\n \n variable k_factor: Number = 1;\n if (min_st > 0.0001 && c_max > 0.0001) [\n k_factor = c_max / min_st;\n ];\n \n // C_mid = 0.9 * k * sqrt(sqrt(1 / (1/ca^4 + 1/cb^4)))\n variable ca: Number = lab_l * st_mid_s;\n variable cb: Number = (1 - lab_l) * st_mid_t;\n variable ca4: Number = ca * ca * ca * ca;\n variable cb4: Number = cb * cb * cb * cb;\n variable c_mid: Number = 0;\n if (ca4 > 0.0000001 && cb4 > 0.0000001) [\n c_mid = 0.9 * k_factor * sqrt(sqrt(1 / (1 / ca4 + 1 / cb4)));\n ];\n \n // C_0 using average ST values (0.4, 0.8)\n variable ca0: Number = lab_l * 0.4;\n variable cb0: Number = (1 - lab_l) * 0.8;\n variable ca0_sq: Number = ca0 * ca0;\n variable cb0_sq: Number = cb0 * cb0;\n variable c_0: Number = 0;\n if (ca0_sq > 0.0000001 && cb0_sq > 0.0000001) [\n c_0 = sqrt(1 / (1 / ca0_sq + 1 / cb0_sq));\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 9: Compute saturation using piecewise interpolation\n // ═══════════════════════════════════════════════════════════════════════\n variable mid: Number = 0.8;\n \n if (c < c_mid && c_mid > 0.0001 && c_0 > 0.0001) [\n // Below mid-point: s = t * 0.8 where t = c / (k1 + k2*c)\n variable k1_low: Number = mid * c_0;\n variable k2_low: Number = 1 - k1_low / c_mid;\n variable t_low: Number = c / (k1_low + k2_low * c + 0.0001);\n s = t_low * mid;\n ] else [\n if (c_mid > 0.0001 && c_0 > 0.0001 && c_max > c_mid) [\n // Above mid-point: s = 0.8 + 0.2 * t where t = (c - c_mid) / (k1 + k2*(c - c_mid))\n variable mid_inv: Number = 1.25;\n variable k0_high: Number = c_mid;\n variable k1_high: Number = 0.2 * c_mid * c_mid * mid_inv * mid_inv / c_0;\n variable k2_high: Number = 1 - k1_high / (c_max - c_mid + 0.0001);\n variable c_diff: Number = c - k0_high;\n variable t_high: Number = c_diff / (k1_high + k2_high * c_diff + 0.0001);\n s = mid + 0.2 * t_high;\n ];\n ];\n \n // Clamp saturation\n if (s > 1) [ s = 1; ];\n if (s < 0) [ s = 0; ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKHSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsv-color/0/", - schema: { - "name": "OKHSV", - "type": "color", - "description": "OKHSV color space by Björn Ottosson - a perceptually uniform HSV based on OKLab. H is hue (0-360), S is saturation (0-1), V is value (0-1). Reference: https://bottosson.github.io/posts/colorpicker/", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees), same as OKLCH hue" - }, - "s": { - "type": "number", - "description": "Saturation (0-1), ratio of chroma to maximum at this value" - }, - "v": { - "type": "number", - "description": "Value/brightness (0-1), with V=1 being the brightest for that saturation" - } - }, - "required": [ - "h", - "s", - "v" - ], - "order": [ - "h", - "s", - "v" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKHSV Color Initializer", - "keyword": "okhsv", - "description": "Creates an OKHSV color from H, S, V values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSV Color Initializer\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n//\n// Creates an OKHSV color from hue, saturation, and value.\n// OKHSV is a perceptually uniform version of HSV built on OKLab.\n//\n// Parameters:\n// - h: Hue angle (0-360 degrees), same as OKLCH hue\n// - s: Saturation (0-1), ratio of chroma to maximum at this value\n// - v: Value/brightness (0-1), with V=1 being brightest for that saturation\n// - alpha: Optional alpha channel (0-1)\n//\n// Input: Object with h, s, v, and optional alpha properties\n// Output: Color.OKHSV\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable v: Number = input.v;\n\nvariable output: Color.OKHSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\n\n// Set alpha if provided\nif (input.alpha != null) [\n output.alpha = input.alpha;\n];\n\noutput" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKHSV using Ottosson's algorithm with Halley's method refinement for gamut boundary", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKHSV Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsv.js\n// Reference: https://bottosson.github.io/posts/oklab/ (original OKLab paper)\n//\n// OKHSV maps colors to a perceptually uniform HSV cylinder.\n// The algorithm finds the exact gamut boundary using compute_max_saturation\n// and positions colors relative to the cusp (maximum chroma point).\n//\n// Key difference from OKHSL:\n// - V=1 means maximum brightness (white at S=0, cusp color at S=1)\n// - More intuitive for picking saturated colors\n//\n// Input: Color.OKLab with l (0-1), a, b coordinates\n// Output: Color.OKHSV with h (0-360), s (0-1), v (0-1)\n\nvariable lab_l: Number = input.l;\nvariable lab_a: Number = input.a;\nvariable lab_b: Number = input.b;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// Toe function constants (same as OKHSL)\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Convert to polar coordinates (L, C, H)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = sqrt(lab_a * lab_a + lab_b * lab_b);\nvariable h: Number = 0;\n\nif (c > 0.00001) [\n // Note: ColorJS uses atan2(-b, -a) and adds 0.5, we use atan2(b, a)\n h = atan2(lab_b, lab_a) * 180 / pi_val;\n if (h < 0) [\n h = h + 360;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Initialize defaults and apply toe function to L for V\n// toe(x) = 0.5 * (k3*x - k1 + sqrt((k3*x - k1)^2 + 4*k2*k3*x))\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s: Number = 0;\nvariable v: Number = lab_l;\n\n// Apply toe function to get initial V\nif (lab_l > 0.0001 && lab_l < 0.9999) [\n variable term: Number = toe_k3 * lab_l - toe_k1;\n v = 0.5 * (term + sqrt(term * term + 4 * toe_k2 * toe_k3 * lab_l));\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Compute normalized hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable a_: Number = 0;\nvariable b_: Number = 0;\nif (c > 0.00001) [\n a_ = lab_a / c;\n b_ = lab_b / c;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in multiple steps)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find maximum saturation and cusp\n// Only process if we have chroma (non-achromatic) and L is valid\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\n// Variables for LMS intermediate calculations\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (c > 0.00001 && lab_l > 0.00001 && lab_l < 0.99999) [\n // Determine which RGB component clips first\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n // Polynomial approximation\n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Find cusp (L_cusp, C_cusp)\n // ═══════════════════════════════════════════════════════════════════════\n if (s_max > 0) [\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Compute OKHSV S and V using ColorJS algorithm\n//\n// The algorithm uses the ST coordinate system where:\n// - S_t = C/L (slope from origin)\n// - T_t = C/(1-L) (slope from white)\n//\n// Key formulas from ColorJS:\n// - t = tMax / (c + l * tMax)\n// - lv = t * l, cv = t * c\n// - Apply RGB scaling and toe compensation\n// - v = l / lv, s = ((s0 + tMax) * cv) / (tMax * s0 + tMax * k * cv)\n// ═══════════════════════════════════════════════════════════════════════════\nif (c > 0.00001 && lab_l > 0.00001 && lab_l < 0.99999 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n // Compute ST values at cusp\n variable s_t_cusp: Number = c_cusp / l_cusp;\n variable t_t_cusp: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Fixed s0 parameter\n variable s_0: Number = 0.5;\n variable k_param: Number = 1 - s_0 / s_t_cusp;\n \n // Compute t and derived values (following ColorJS exactly)\n variable t: Number = t_t_cusp / (c + lab_l * t_t_cusp + 0.0001);\n variable lv: Number = t * lab_l;\n variable cv: Number = t * c;\n \n // Apply inverse toe to lv for compensation\n variable lvt: Number = lv;\n if (lv > 0.0001 && lv < 0.9999) [\n lvt = (lv * lv + toe_k1 * lv) / (toe_k3 * (lv + toe_k2));\n ];\n \n variable cvt: Number = cv;\n if (lv > 0.0001) [\n cvt = cv * lvt / lv;\n ];\n \n // RGB scale computation\n variable scale_l_: Number = 1 + s_max * kl;\n variable scale_m_: Number = 1 + s_max * km;\n variable scale_s_: Number = 1 + s_max * ks;\n \n variable lms_l: Number = lvt + a_ * cvt * kl;\n variable lms_m: Number = lvt + a_ * cvt * km;\n variable lms_s: Number = lvt + a_ * cvt * ks;\n \n // Convert to linear RGB using LMS\n variable lms_l_cubed: Number = lms_l * lms_l * lms_l;\n variable lms_m_cubed: Number = lms_m * lms_m * lms_m;\n variable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n \n variable rs: Number = 4.0767416621 * lms_l_cubed - 3.3077115913 * lms_m_cubed + 0.2309699292 * lms_s_cubed;\n variable gs: Number = -1.2684380046 * lms_l_cubed + 2.6097574011 * lms_m_cubed - 0.3413193965 * lms_s_cubed;\n variable bs: Number = -0.0041960863 * lms_l_cubed - 0.7034186147 * lms_m_cubed + 1.7076147010 * lms_s_cubed;\n \n variable max_s: Number = rs;\n if (gs > max_s) [ max_s = gs; ];\n if (bs > max_s) [ max_s = bs; ];\n if (max_s < 0.0001) [ max_s = 0.0001; ];\n \n variable scale_l: Number = pow(1 / max_s, 0.3333333333333333);\n \n // Scale L and C\n variable l_scaled: Number = lab_l / scale_l;\n variable c_scaled: Number = c / scale_l;\n \n // Apply toe to scaled L for compensation\n variable l_toed: Number = l_scaled;\n if (l_scaled > 0.0001 && l_scaled < 0.9999) [\n variable term2: Number = toe_k3 * l_scaled - toe_k1;\n l_toed = 0.5 * (term2 + sqrt(term2 * term2 + 4 * toe_k2 * toe_k3 * l_scaled));\n ];\n \n c_scaled = c_scaled * l_toed / (l_scaled + 0.0001);\n \n // Compute final v and s\n if (lv > 0.0001) [\n v = l_toed / lv;\n if (v > 1) [ v = 1; ];\n if (v < 0) [ v = 0; ];\n ];\n \n variable denom_s: Number = t_t_cusp * s_0 + t_t_cusp * k_param * cv;\n if (abs(denom_s) > 0.0001) [\n s = ((s_0 + t_t_cusp) * cv) / denom_s;\n if (s > 1) [ s = 1; ];\n if (s < 0) [ s = 0; ];\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKHSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - schema: { - "name": "P3", - "type": "color", - "description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Red channel (0-1, can exceed for out-of-gamut)" - }, - "g": { - "type": "number", - "description": "Green channel (0-1, can exceed for out-of-gamut)" - }, - "b": { - "type": "number", - "description": "Blue channel (0-1, can exceed for out-of-gamut)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Display-P3 Color Initializer", - "keyword": "p3", - "description": "Creates a Display-P3 color from 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - "target": "$self", - "description": "Converts Linear P3 to P3 by applying sRGB transfer function", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/css-color/0/", - schema: { - "name": "CSS", - "type": "color", - "description": "CSS color string representation. Outputs the appropriate CSS syntax for any color space.", - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "description": "CSS color string (e.g., 'rgb(255 128 64)', 'oklch(0.7 0.15 180)')" - } - }, - "required": [ - "value" - ] - }, - "initializers": [ - { - "title": "CSS Color Initializer", - "keyword": "css", - "description": "Creates a CSS color from a string value", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CSS Color Initializer\n// Accepts a CSS color string and returns it\n// This is primarily used for type registration; \n// the main usage is converting TO css from other color types\n//\n// Input: String (CSS color value)\n// Output: String (same value)\n\nvariable input: List = {input};\nvariable value: String = input.get(0);\n\nreturn value;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - "target": "$self", - "description": "Converts RGB (0-255) to CSS rgb() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to CSS Conversion\n// Converts RGB (0-255) to CSS rgb() syntax\n// CSS Color Level 4 modern syntax: rgb(r g b) or rgb(r g b / alpha)\n//\n// Input: Color.Rgb with r, g, b in 0-255 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"rgb(255 128 64)\" or \"rgb(255 128 64 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable r: Number = round({input}.r);\nvariable g: Number = round({input}.g);\nvariable b: Number = round({input}.b);\n\nvariable css_value: String = \"rgb(\".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB (0-1) to CSS color(srgb) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to CSS Conversion\n// Converts sRGB (0-1) to CSS color(srgb) syntax\n// CSS Color Level 4: color(srgb r g b) or color(srgb r g b / alpha)\n//\n// Input: Color.SRGB with r, g, b in 0-1 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(srgb 1 0.5 0.25)\" or \"color(srgb 1 0.5 0.25 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"color(srgb \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to CSS hsl() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to CSS Conversion\n// Converts HSL to CSS hsl() syntax\n// CSS Color Level 4: hsl(h s l) or hsl(h s l / alpha) where s and l are percentages\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1), optional alpha (0-1)\n// Output: Color.CSS with value like \"hsl(120 50% 75%)\" or \"hsl(120 50% 75% / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100;\nvariable h: Number = round({input}.h * precision) / precision;\nvariable s: Number = round({input}.s * 100 * precision) / precision;\nvariable l: Number = round({input}.l * 100 * precision) / precision;\n\nvariable css_value: String = \"hsl(\".concat(h.to_string()).concat(\" \").concat(s.to_string()).concat(\"% \").concat(l.to_string()).concat(\"%\");\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hwb-color/0/", - "target": "$self", - "description": "Converts HWB to CSS hwb() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWB to CSS Conversion\n// Converts HWB to CSS hwb() syntax\n// CSS Color Level 4: hwb(h w b) or hwb(h w b / alpha) where w and b are percentages\n//\n// Input: Color.HWB with h (0-360), w (0-1), b (0-1), optional alpha (0-1)\n// Output: Color.CSS with value like \"hwb(120 10% 20%)\" or \"hwb(120 10% 20% / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100;\nvariable h: Number = round({input}.h * precision) / precision;\nvariable w: Number = round({input}.w * 100 * precision) / precision;\nvariable b_val: Number = round({input}.b * 100 * precision) / precision;\n\nvariable css_value: String = \"hwb(\".concat(h.to_string()).concat(\" \").concat(w.to_string()).concat(\"% \").concat(b_val.to_string()).concat(\"%\");\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - "target": "$self", - "description": "Converts CIE Lab to CSS lab() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab to CSS Conversion\n// Converts CIE Lab to CSS lab() syntax\n// CSS Color Level 4: lab(L a b) or lab(L a b / alpha) where L is a percentage\n//\n// Input: Color.Lab with l (0-100), a, b, optional alpha (0-1)\n// Output: Color.CSS with value like \"lab(75% 20 -30)\" or \"lab(75% 20 -30 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 10000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable a: Number = round({input}.a * precision) / precision;\nvariable b_val: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"lab(\".concat(l.to_string()).concat(\"% \").concat(a.to_string()).concat(\" \").concat(b_val.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lch-color/0/", - "target": "$self", - "description": "Converts CIE LCH to CSS lch() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE LCH to CSS Conversion\n// Converts CIE LCH to CSS lch() syntax\n// CSS Color Level 4: lch(L C H) or lch(L C H / alpha) where L is a percentage\n//\n// Input: Color.LCH with l (0-100), c, h (0-360), optional alpha (0-1)\n// Output: Color.CSS with value like \"lch(75% 50 180)\" or \"lch(75% 50 180 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 10000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable c: Number = round({input}.c * precision) / precision;\nvariable h: Number = round({input}.h * precision) / precision;\n\nvariable css_value: String = \"lch(\".concat(l.to_string()).concat(\"% \").concat(c.to_string()).concat(\" \").concat(h.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to CSS oklab() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to CSS Conversion\n// Converts OKLab to CSS oklab() syntax\n// CSS Color Level 4: oklab(L a b) or oklab(L a b / alpha) where L is 0-1 decimal\n//\n// Input: Color.OKLab with l (0-1), a, b, optional alpha (0-1)\n// Output: Color.CSS with value like \"oklab(0.7 0.1 -0.05)\" or \"oklab(0.7 0.1 -0.05 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable a: Number = round({input}.a * precision) / precision;\nvariable b_val: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"oklab(\".concat(l.to_string()).concat(\" \").concat(a.to_string()).concat(\" \").concat(b_val.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to CSS oklch() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to CSS Conversion\n// Converts OKLCH to CSS oklch() syntax\n// CSS Color Level 4: oklch(L C H) or oklch(L C H / alpha) where L is 0-1 decimal\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360), optional alpha (0-1)\n// Output: Color.CSS with value like \"oklch(0.7 0.15 180)\" or \"oklch(0.7 0.15 180 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable c: Number = round({input}.c * precision) / precision;\nvariable h: Number = round({input}.h * precision) / precision;\n\nvariable css_value: String = \"oklch(\".concat(l.to_string()).concat(\" \").concat(c.to_string()).concat(\" \").concat(h.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to CSS color(srgb-linear) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to CSS Conversion\n// Converts Linear sRGB to CSS color(srgb-linear) syntax\n// CSS Color Level 4: color(srgb-linear r g b)\n//\n// Input: Color.LinearSRGB with r, g, b in 0-1 range\n// Output: Color.CSS with value like \"color(srgb-linear 1 0.25 0.0625)\"\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable output: Color.CSS;\noutput.value = \"color(srgb-linear \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string()).concat(\")\");\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to CSS color(xyz-d65) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to CSS Conversion\n// Converts XYZ-D65 to CSS color(xyz-d65) syntax\n// CSS Color Level 4: color(xyz-d65 x y z) or color(xyz-d65 x y z / alpha)\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(xyz-d65 0.4124 0.2126 0.0193)\" or \"color(xyz-d65 0.4124 0.2126 0.0193 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable x: Number = round({input}.x * precision) / precision;\nvariable y: Number = round({input}.y * precision) / precision;\nvariable z: Number = round({input}.z * precision) / precision;\n\nvariable css_value: String = \"color(xyz-d65 \".concat(x.to_string()).concat(\" \").concat(y.to_string()).concat(\" \").concat(z.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - "target": "$self", - "description": "Converts XYZ-D50 to CSS color(xyz-d50) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 to CSS Conversion\n// Converts XYZ-D50 to CSS color(xyz-d50) syntax\n// CSS Color Level 4: color(xyz-d50 x y z) or color(xyz-d50 x y z / alpha)\n//\n// Input: Color.XYZD50 with x, y, z tristimulus values, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(xyz-d50 0.4360 0.2225 0.0139)\" or \"color(xyz-d50 0.4360 0.2225 0.0139 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable x: Number = round({input}.x * precision) / precision;\nvariable y: Number = round({input}.y * precision) / precision;\nvariable z: Number = round({input}.z * precision) / precision;\n\nvariable css_value: String = \"color(xyz-d50 \".concat(x.to_string()).concat(\" \").concat(y.to_string()).concat(\" \").concat(z.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - "target": "$self", - "description": "Converts Display-P3 to CSS color(display-p3) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display-P3 to CSS Conversion\n// Converts Display-P3 to CSS color(display-p3) syntax\n// CSS Color Level 4: color(display-p3 r g b) or color(display-p3 r g b / alpha)\n//\n// Input: Color.P3 with r, g, b in 0-1 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(display-p3 1 0.5 0.25)\" or \"color(display-p3 1 0.5 0.25 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"color(display-p3 \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hwb-color/0/", - schema: { - "name": "HWB", - "type": "color", - "description": "HWB color space - Hue, Whiteness, Blackness. CSS Color Level 4.", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "w": { - "type": "number", - "description": "Whiteness (0-1)" - }, - "b": { - "type": "number", - "description": "Blackness (0-1)" - } - }, - "required": [ - "h", - "w", - "b" - ], - "order": [ - "h", - "w", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HWB Color Initializer", - "keyword": "hwb", - "description": "Creates an HWB color from H, W, B values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWB Color Initializer\n// Creates an HWB color from H, W, B values\n// Input: List of [h, w, b] or [h, w, b, alpha] values\n\nvariable hwb_values: List = {input};\nvariable output: Color.HWB;\n\noutput.h = hwb_values.get(0);\noutput.w = hwb_values.get(1);\noutput.b = hwb_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hwb_values.length() > 3) [\n output.alpha = hwb_values.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "HWBA Color Initializer", - "keyword": "hwba", - "description": "Creates an HWB color with alpha from H, W, B, A values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWBA Color Initializer\n// Creates an HWB color with alpha from H, W, B, A values\n//\n// Usage: hwba(180, 0.2, 0.3, 0.9) → Color.HWB { h: 180, w: 0.2, b: 0.3, alpha: 0.9 }\n//\n// Input: List of 4 numbers [h, w, b, alpha]\n// Output: Color.HWB with alpha\n\nvariable hwb_values: List = {input};\nvariable output: Color.HWB;\n\noutput.h = hwb_values.get(0);\noutput.w = hwb_values.get(1);\noutput.b = hwb_values.get(2);\noutput.alpha = hwb_values.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsv-color/0/", - "target": "$self", - "description": "Converts HSV to HWB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSV to HWB Conversion\n// Reference: CSS Color Level 4\n//\n// Algorithm:\n// H stays the same\n// W = (1 - S) * V (Whiteness)\n// B = 1 - V (Blackness)\n//\n// Input: Color.HSV with h, s, v values\n// Output: Color.HWB with h, w, b values\n\n// Get input HSV values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable v: Number = {input}.v;\n\n// Calculate whiteness and blackness\nvariable w: Number = (1 - s) * v;\nvariable b: Number = 1 - v;\n\n// Create output\nvariable output: Color.HWB;\noutput.h = h;\noutput.w = w;\noutput.b = b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsv-color/0/", - schema: { - "name": "HSV", - "type": "color", - "description": "HSV color space - Hue, Saturation, Value. Also known as HSB (Hue, Saturation, Brightness).", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "s": { - "type": "number", - "description": "Saturation (0-1)" - }, - "v": { - "type": "number", - "description": "Value/Brightness (0-1)" - } - }, - "required": [ - "h", - "s", - "v" - ], - "order": [ - "h", - "s", - "v" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HSV Color Initializer", - "keyword": "hsv", - "description": "Creates an HSV color from H, S, V values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSV Color Initializer\n// Creates an HSV color from H, S, V values\n// Input: List of [h, s, v] or [h, s, v, alpha] values\n\nvariable hsv_values: List = {input};\nvariable output: Color.HSV;\n\noutput.h = hsv_values.get(0);\noutput.s = hsv_values.get(1);\noutput.v = hsv_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hsv_values.length() > 3) [\n output.alpha = hsv_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to HSV", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to HSV Conversion\n// Reference: Standard RGB to HSV algorithm\n//\n// Algorithm:\n// V = max(R, G, B)\n// S = (V - min(R, G, B)) / V (if V > 0)\n// H depends on which channel is max\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.HSV with h (0-360), s (0-1), v (0-1)\n\n// Get input sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Find max and min\nvariable max_val: Number = r;\nif (g > max_val) [\n max_val = g;\n];\nif (b > max_val) [\n max_val = b;\n];\n\nvariable min_val: Number = r;\nif (g < min_val) [\n min_val = g;\n];\nif (b < min_val) [\n min_val = b;\n];\n\n// Value is the max channel\nvariable v: Number = max_val;\n\n// Calculate saturation and hue\nvariable s: Number = 0;\nvariable h: Number = 0;\nvariable delta: Number = max_val - min_val;\n\nif (max_val > 0) [\n s = delta / max_val;\n];\n\nif (delta > 0) [\n // Hue calculation depends on which channel is max\n if (max_val == r) [\n h = ((g - b) / delta);\n if (g < b) [\n h = h + 6;\n ];\n ] else [\n if (max_val == g) [\n h = (b - r) / delta + 2;\n ] else [\n h = (r - g) / delta + 4;\n ];\n ];\n \n // Convert to degrees\n h = h * 60;\n];\n\n// Normalize hue to 0-360\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.HSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - schema: { - "name": "Lab", - "type": "color", - "description": "CIE Lab (L*a*b*) perceptually uniform color space. L is lightness (0-100), a is green-red axis, b is blue-yellow axis.", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-100)" - }, - "a": { - "type": "number", - "description": "Green-red axis (typically -125 to 125)" - }, - "b": { - "type": "number", - "description": "Blue-yellow axis (typically -125 to 125)" - } - }, - "required": [ - "l", - "a", - "b" - ], - "order": [ - "l", - "a", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Lab Color Initializer", - "keyword": "lab", - "description": "Creates a CIE Lab color from L, a, b values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab Color Initializer\n// Creates a Lab color from L, a, b values\n// Input: List of [l, a, b] or [l, a, b, alpha] values\n\nvariable lab_values: List = {input};\nvariable output: Color.Lab;\n\noutput.l = lab_values.get(0);\noutput.a = lab_values.get(1);\noutput.b = lab_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lab_values.length() > 3) [\n output.alpha = lab_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - "target": "$self", - "description": "Converts XYZ-D50 to CIE Lab using the CIE standard formula", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 to CIE Lab Conversion\n// Reference: CIE 15.3:2004 section 8.2.1.1\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/lab.js\n//\n// Algorithm:\n// 1. Scale XYZ by D50 white point reference\n// 2. Apply f function: f(x) = x > ε ? cbrt(x) : (κ*x + 16)/116\n// 3. L = 116 * f[Y] - 16\n// 4. a = 500 * (f[X] - f[Y])\n// 5. b = 200 * (f[Y] - f[Z])\n//\n// Input: Color.XYZD50 with x, y, z tristimulus\n// Output: Color.Lab with l, a, b coordinates\n\n// Get input XYZ-D50 values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// CIE constants (exact rational fractions)\n// ε = 216/24389 = (6/29)^3\nvariable epsilon: Number = 0.008856451679035631;\n// κ = 24389/27 = (29/3)^3 \nvariable kappa: Number = 903.2962962962963;\n\n// D50 white point reference (ColorJS exact values)\n// D50: [0.3457/0.3585, 1.0, (1-0.3457-0.3585)/0.3585]\nvariable white_x: Number = 0.9642956764295677;\nvariable white_y: Number = 1.0;\nvariable white_z: Number = 0.8251046025104602;\n\n// Scale XYZ by white point\nvariable xr: Number = x / white_x;\nvariable yr: Number = y / white_y;\nvariable zr: Number = z / white_z;\n\n// Apply f function with cube root\n// f(t) = t > ε ? t^(1/3) : (κt + 16) / 116\nvariable cube_root_exp: Number = 0.3333333333333333;\n\nvariable fx: Number = 0;\nvariable fy: Number = 0;\nvariable fz: Number = 0;\n\nif (xr > epsilon) [\n fx = pow(xr, cube_root_exp);\n] else [\n fx = (kappa * xr + 16) / 116;\n];\n\nif (yr > epsilon) [\n fy = pow(yr, cube_root_exp);\n] else [\n fy = (kappa * yr + 16) / 116;\n];\n\nif (zr > epsilon) [\n fz = pow(zr, cube_root_exp);\n] else [\n fz = (kappa * zr + 16) / 116;\n];\n\n// Calculate Lab values\nvariable lab_l: Number = 116 * fy - 16;\nvariable lab_a: Number = 500 * (fx - fy);\nvariable lab_b: Number = 200 * (fy - fz);\n\n// Create output\nvariable output: Color.Lab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - schema: { - "name": "XYZD50", - "type": "color", - "description": "CIE XYZ color space with D50 white point. Used for Lab and LCH color spaces.", - "schema": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "X tristimulus value" - }, - "y": { - "type": "number", - "description": "Y tristimulus value (luminance)" - }, - "z": { - "type": "number", - "description": "Z tristimulus value" - } - }, - "required": [ - "x", - "y", - "z" - ], - "order": [ - "x", - "y", - "z" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "XYZ-D50 Color Initializer", - "keyword": "xyzd50", - "description": "Creates an XYZ-D50 color from tristimulus values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 Color Initializer\n// Creates an XYZ-D50 color from tristimulus values\n// Input: List of [x, y, z] tristimulus values\n\nvariable xyz_values: List = {input};\nvariable output: Color.XYZD50;\n\noutput.x = xyz_values.get(0);\noutput.y = xyz_values.get(1);\noutput.z = xyz_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to XYZ-D50 using Bradford chromatic adaptation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to XYZ-D50 Conversion\n// Chromatic adaptation using Bradford transform\n// Reference: https://github.com/color-js/color.js/blob/main/src/adapt.js\n//\n// Bradford CAT matrix for D65 → D50:\n// [1.0479298 0.0229469 -0.0501923]\n// [0.0296278 0.9904344 -0.0170738]\n// [-0.0092430 0.0150552 0.7518743]\n//\n// Input: Color.XYZD65 with x, y, z tristimulus (D65 white)\n// Output: Color.XYZD50 with x, y, z tristimulus (D50 white)\n\n// Get input XYZ-D65 values\nvariable x65: Number = {input}.x;\nvariable y65: Number = {input}.y;\nvariable z65: Number = {input}.z;\n\n// Bradford chromatic adaptation matrix (ColorJS exact values)\n// Row 1\nvariable x50: Number = x65 * 1.0479297925449969 + y65 * 0.022946870601609652 + z65 * -0.05019226628920524;\n// Row 2\nvariable y50: Number = x65 * 0.02962780877005599 + y65 * 0.9904344267538799 + z65 * -0.017073799063418826;\n// Row 3\nvariable z50: Number = x65 * -0.009243040646204504 + y65 * 0.015055191490298152 + z65 * 0.7518742814281371;\n\n// Create output\nvariable output: Color.XYZD50;\noutput.x = x50;\noutput.y = y50;\noutput.z = z50;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lch-color/0/", - schema: { - "name": "LCH", - "type": "color", - "description": "CIE LCH color space - the polar form of CIE Lab. L is lightness (0-100), C is chroma, H is hue angle (0-360).", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-100)" - }, - "c": { - "type": "number", - "description": "Chroma" - }, - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - } - }, - "required": [ - "l", - "c", - "h" - ], - "order": [ - "l", - "c", - "h" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "LCH Color Initializer", - "keyword": "lch", - "description": "Creates a CIE LCH color from L, C, H values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE LCH Color Initializer\n// Creates a LCH color from L, C, H values\n// Input: List of [l, c, h] or [l, c, h, alpha] values\n\nvariable lch_values: List = {input};\nvariable output: Color.LCH;\n\noutput.l = lch_values.get(0);\noutput.c = lch_values.get(1);\noutput.h = lch_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lch_values.length() > 3) [\n output.alpha = lch_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - "target": "$self", - "description": "Converts CIE Lab to LCH using Cartesian to polar transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab to LCH Conversion\n// Converts Cartesian (a, b) to polar (C, H) coordinates\n// Same algorithm as OKLab → OKLCH, but for CIE Lab\n//\n// Algorithm:\n// L stays the same (lightness 0-100)\n// C = sqrt(a² + b²) (chroma)\n// H = atan2(b, a) * 180/π (hue angle in degrees)\n//\n// Input: Color.Lab with l, a, b coordinates\n// Output: Color.LCH with l, c, h coordinates\n\n// Get input Lab values\nvariable l: Number = {input}.l;\nvariable a: Number = {input}.a;\nvariable b: Number = {input}.b;\n\n// Constants\nvariable pi: Number = pi();\nvariable rad_to_deg: Number = 180 / pi;\n\n// Calculate chroma (distance from neutral axis)\nvariable c: Number = sqrt(a * a + b * b);\n\n// Calculate hue angle using atan2\nvariable h_rad: Number = atan2(b, a);\nvariable h: Number = h_rad * rad_to_deg;\n\n// Normalize hue to 0-360 range\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.LCH;\noutput.l = l;\noutput.c = c;\noutput.h = h;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/lighten/0/", - schema: { - "name": "Lighten", - "type": "function", - "description": "Makes a color lighter by increasing its lightness in OKLab space. Amount is 0-1 where 0.25 = 25% lighter.", - "keyword": "lighten", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to lighten" - }, - "amount": { - "type": "number", - "description": "Amount to lighten (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// lighten: Increase lightness proportionally towards white\n// Amount: 0-1 where 0.25 means 25% closer to white\n//\n// Algorithm: L' = L + (1 - L) * amount\n// This ensures we approach white (L=1) proportionally.\n//\n// Input: Any color space (converted to OKLab internally)\n// Output: OKLCH (working space)\n// To get sRGB: lighten(color, 0.25).to.srgb()\n\nvariable input: List = {input};\nvariable color: Color.OKLab = input.get(0).to.oklab();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new lightness (move toward 1)\nvariable current_l: Number = color.l;\nvariable new_l: Number = current_l + (1 - current_l) * amount;\n\n// Clamp to valid range\nif (new_l > 1) [ new_l = 1; ];\n\n// Create output in OKLab, return as OKLCH (working space)\nvariable result: Color.OKLab;\nresult.l = new_l;\nresult.a = color.a;\nresult.b = color.b;\n\nreturn result.to.oklch();" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/darken/0/", - schema: { - "name": "Darken", - "type": "function", - "description": "Makes a color darker by decreasing its lightness in OKLab space. Amount is 0-1 where 0.25 = 25% darker.", - "keyword": "darken", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to darken" - }, - "amount": { - "type": "number", - "description": "Amount to darken (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// darken: Decrease lightness proportionally towards black\n// Amount: 0-1 where 0.25 means 25% closer to black\n//\n// Algorithm: L' = L * (1 - amount)\n// This ensures we approach black (L=0) proportionally.\n//\n// Input: Any color space (converted to OKLab internally)\n// Output: OKLCH (working space)\n// To get sRGB: darken(color, 0.25).to.srgb()\n\nvariable input: List = {input};\nvariable color: Color.OKLab = input.get(0).to.oklab();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new lightness (move toward 0)\nvariable current_l: Number = color.l;\nvariable new_l: Number = current_l * (1 - amount);\n\n// Clamp to valid range\nif (new_l < 0) [ new_l = 0; ];\n\n// Create output in OKLab, return as OKLCH (working space)\nvariable result: Color.OKLab;\nresult.l = new_l;\nresult.a = color.a;\nresult.b = color.b;\n\nreturn result.to.oklch();" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/saturate/0/", - schema: { - "name": "Saturate", - "type": "function", - "description": "Increases color saturation by boosting chroma in OKLCH space. Amount is 0-1 where 0.25 = 25% more saturated.", - "keyword": "saturate", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to saturate" - }, - "amount": { - "type": "number", - "description": "Amount to saturate (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Saturate a color by increasing its OKLCH chroma\n// Amount: 0-1 where 0.25 means 25% increase\n//\n// Algorithm: C' = C * (1 + amount)\n// Chroma has no strict upper bound but ~0.4 is practical max for sRGB\n\nvariable input: List = {input};\nvariable color: Color.OKLCH = input.get(0).to.oklch();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new chroma\nvariable current_c: Number = color.c;\nvariable new_c: Number = current_c * (1 + amount);\n\n// Soft clamp at 0.4 (approximate sRGB gamut max)\nif (new_c > 0.4) [ new_c = 0.4; ];\n\n// Create output in OKLCH, return as sRGB\nvariable result: Color.OKLCH;\nresult.l = color.l;\nresult.c = new_c;\nresult.h = color.h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/desaturate/0/", - schema: { - "name": "Desaturate", - "type": "function", - "description": "Decreases color saturation by reducing chroma in OKLCH space. Amount is 0-1 where 0.25 = 25% less saturated.", - "keyword": "desaturate", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to desaturate" - }, - "amount": { - "type": "number", - "description": "Amount to desaturate (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Desaturate a color by decreasing its OKLCH chroma\n// Amount: 0-1 where 0.25 means 25% decrease, 1.0 = full grayscale\n//\n// Algorithm: C' = C * (1 - amount)\n\nvariable input: List = {input};\nvariable color: Color.OKLCH = input.get(0).to.oklch();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new chroma\nvariable current_c: Number = color.c;\nvariable new_c: Number = current_c * (1 - amount);\n\n// Clamp to valid range\nif (new_c < 0) [ new_c = 0; ];\n\n// Create output in OKLCH, return as sRGB\nvariable result: Color.OKLCH;\nresult.l = color.l;\nresult.c = new_c;\nresult.h = color.h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/mix/0/", - schema: { - "name": "Mix", - "type": "function", - "description": "Blends two colors together in OKLCH space. Amount 0 = first color, 1 = second color, 0.5 = equal blend.", - "keyword": "mix", - "input": { - "type": "object", - "properties": { - "color1": { - "type": "color", - "description": "First color" - }, - "color2": { - "type": "color", - "description": "Second color" - }, - "amount": { - "type": "number", - "description": "Blend amount (0-1). Default is 0.5" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// mix: Blend two colors in perceptual space\n// Uses shortest hue path for natural color blending.\n//\n// Algorithm: Linear interpolation on L, C, and shortest-path H\n//\n// Input: Any color space (converted to OKLCH internally)\n// Output: OKLCH (working space)\n// To get sRGB: mix(color1, color2, 0.5).to.srgb()\n\nvariable input: List = {input};\nvariable color1: Color.OKLCH = input.get(0).to.oklch();\nvariable color2: Color.OKLCH = input.get(1).to.oklch();\n\n// Default amount is 0.5 (50/50 blend)\nvariable amount: Number = 0.5;\nif (input.length() > 2) [\n amount = input.get(2);\n];\n\n// Interpolate lightness and chroma linearly\nvariable new_l: Number = color1.l + (color2.l - color1.l) * amount;\nvariable new_c: Number = color1.c + (color2.c - color1.c) * amount;\n\n// Interpolate hue using shortest path\nvariable h1: Number = color1.h;\nvariable h2: Number = color2.h;\nvariable h_diff: Number = h2 - h1;\n\n// Normalize to shortest path (-180 to 180)\nif (h_diff > 180) [ h_diff = h_diff - 360; ];\nif (h_diff < -180) [ h_diff = h_diff + 360; ];\n\nvariable new_h: Number = h1 + h_diff * amount;\nif (new_h < 0) [ new_h = new_h + 360; ];\nif (new_h >= 360) [ new_h = new_h - 360; ];\n\n// Create mixed color\nvariable result: Color.OKLCH;\nresult.l = new_l;\nresult.c = new_c;\nresult.h = new_h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/invert/0/", - schema: { - "name": "Invert Color", - "type": "function", - "description": "Inverts a color by inverting each RGB channel (R' = 255 - R, G' = 255 - G, B' = 255 - B).", - "keyword": "invert", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to invert." - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Invert Color Function\n// Returns the inverse/negative of a color by subtracting each RGB channel from 255\n//\n// Algorithm:\n// R' = 255 - R\n// G' = 255 - G \n// B' = 255 - B\n//\n// Use cases:\n// - Creating negative images\n// - High contrast accessibility variants\n// - Artistic color effects\n//\n// Input: Any color (converted to RGB internally)\n// Output: Color.Rgb with inverted channels\n\nvariable input: List = {input};\n\n// Convert input to RGB (0-255 range)\nvariable rgb_color: Color.Rgb = input.get(0).to.rgb();\n\n// Invert each channel by subtracting from 255\nvariable inverted_r: Number = 255 - rgb_color.r;\nvariable inverted_g: Number = 255 - rgb_color.g;\nvariable inverted_b: Number = 255 - rgb_color.b;\n\n// Create and return the inverted color\nvariable inverted_color: Color.Rgb = rgb(inverted_r, inverted_g, inverted_b);\nreturn inverted_color;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/constants/css-hex-colors/0/", - schema: { - "name": "CSS Hex Colors", - "type": "constants", - "description": "CSS named colors mapped to their hex values (CSS Color Level 4)", - "inline": true, - "values": { - "aliceblue": "#F0F8FF", - "antiquewhite": "#FAEBD7", - "aqua": "#00FFFF", - "aquamarine": "#7FFFD4", - "azure": "#F0FFFF", - "beige": "#F5F5DC", - "bisque": "#FFE4C4", - "black": "#000000", - "blanchedalmond": "#FFEBCD", - "blue": "#0000FF", - "blueviolet": "#8A2BE2", - "brown": "#A52A2A", - "burlywood": "#DEB887", - "cadetblue": "#5F9EA0", - "chartreuse": "#7FFF00", - "chocolate": "#D2691E", - "coral": "#FF7F50", - "cornflowerblue": "#6495ED", - "cornsilk": "#FFF8DC", - "crimson": "#DC143C", - "cyan": "#00FFFF", - "darkblue": "#00008B", - "darkcyan": "#008B8B", - "darkgoldenrod": "#B8860B", - "darkgray": "#A9A9A9", - "darkgreen": "#006400", - "darkgrey": "#A9A9A9", - "darkkhaki": "#BDB76B", - "darkmagenta": "#8B008B", - "darkolivegreen": "#556B2F", - "darkorange": "#FF8C00", - "darkorchid": "#9932CC", - "darkred": "#8B0000", - "darksalmon": "#E9967A", - "darkseagreen": "#8FBC8F", - "darkslateblue": "#483D8B", - "darkslategray": "#2F4F4F", - "darkslategrey": "#2F4F4F", - "darkturquoise": "#00CED1", - "darkviolet": "#9400D3", - "deeppink": "#FF1493", - "deepskyblue": "#00BFFF", - "dimgray": "#696969", - "dimgrey": "#696969", - "dodgerblue": "#1E90FF", - "firebrick": "#B22222", - "floralwhite": "#FFFAF0", - "forestgreen": "#228B22", - "fuchsia": "#FF00FF", - "gainsboro": "#DCDCDC", - "ghostwhite": "#F8F8FF", - "gold": "#FFD700", - "goldenrod": "#DAA520", - "gray": "#808080", - "green": "#008000", - "greenyellow": "#ADFF2F", - "grey": "#808080", - "honeydew": "#F0FFF0", - "hotpink": "#FF69B4", - "indianred": "#CD5C5C", - "indigo": "#4B0082", - "ivory": "#FFFFF0", - "khaki": "#F0E68C", - "lavender": "#E6E6FA", - "lavenderblush": "#FFF0F5", - "lawngreen": "#7CFC00", - "lemonchiffon": "#FFFACD", - "lightblue": "#ADD8E6", - "lightcoral": "#F08080", - "lightcyan": "#E0FFFF", - "lightgoldenrodyellow": "#FAFAD2", - "lightgray": "#D3D3D3", - "lightgreen": "#90EE90", - "lightgrey": "#D3D3D3", - "lightpink": "#FFB6C1", - "lightsalmon": "#FFA07A", - "lightseagreen": "#20B2AA", - "lightskyblue": "#87CEFA", - "lightslategray": "#778899", - "lightslategrey": "#778899", - "lightsteelblue": "#B0C4DE", - "lightyellow": "#FFFFE0", - "lime": "#00FF00", - "limegreen": "#32CD32", - "linen": "#FAF0E6", - "magenta": "#FF00FF", - "maroon": "#800000", - "mediumaquamarine": "#66CDAA", - "mediumblue": "#0000CD", - "mediumorchid": "#BA55D3", - "mediumpurple": "#9370DB", - "mediumseagreen": "#3CB371", - "mediumslateblue": "#7B68EE", - "mediumspringgreen": "#00FA9A", - "mediumturquoise": "#48D1CC", - "mediumvioletred": "#C71585", - "midnightblue": "#191970", - "mintcream": "#F5FFFA", - "mistyrose": "#FFE4E1", - "moccasin": "#FFE4B5", - "navajowhite": "#FFDEAD", - "navy": "#000080", - "oldlace": "#FDF5E6", - "olive": "#808000", - "olivedrab": "#6B8E23", - "orange": "#FFA500", - "orangered": "#FF4500", - "orchid": "#DA70D6", - "palegoldenrod": "#EEE8AA", - "palegreen": "#98FB98", - "paleturquoise": "#AFEEEE", - "palevioletred": "#DB7093", - "papayawhip": "#FFEFD5", - "peachpuff": "#FFDAB9", - "peru": "#CD853F", - "pink": "#FFC0CB", - "plum": "#DDA0DD", - "powderblue": "#B0E0E6", - "purple": "#800080", - "rebeccapurple": "#663399", - "red": "#FF0000", - "rosybrown": "#BC8F8F", - "royalblue": "#4169E1", - "saddlebrown": "#8B4513", - "salmon": "#FA8072", - "sandybrown": "#F4A460", - "seagreen": "#2E8B57", - "seashell": "#FFF5EE", - "sienna": "#A0522D", - "silver": "#C0C0C0", - "skyblue": "#87CEEB", - "slateblue": "#6A5ACD", - "slategray": "#708090", - "slategrey": "#708090", - "snow": "#FFFAFA", - "springgreen": "#00FF7F", - "steelblue": "#4682B4", - "tan": "#D2B48C", - "teal": "#008080", - "thistle": "#D8BFD8", - "tomato": "#FF6347", - "turquoise": "#40E0D0", - "violet": "#EE82EE", - "wheat": "#F5DEB3", - "white": "#FFFFFF", - "whitesmoke": "#F5F5F5", - "yellow": "#FFFF00", - "yellowgreen": "#9ACD32" - } - } - }, -]; - -export function makeConfig() { - return new Config().registerSchemas(SCHEMAS); -} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3f045541a8..5f1dad88cd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -210,9 +210,6 @@ importers: '@penpot/text-editor': specifier: link:text-editor version: link:text-editor - '@penpot/tokenscript': - specifier: link:packages/tokenscript - version: link:packages/tokenscript '@penpot/ua-parser': specifier: penpot/ua-parser#1.0.0 version: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4 @@ -445,12 +442,6 @@ importers: packages/mousetrap: {} - packages/tokenscript: - dependencies: - '@tokens-studio/tokenscript-interpreter': - specifier: ^0.26.0 - version: 0.26.0 - packages/ui: dependencies: react: @@ -547,12 +538,6 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@ark/schema@0.56.6': - resolution: {integrity: sha512-OmLv80BCF2ib+zjecrXDosip/CSnZSYBGjblhqWYZWFOzdLQka6QymHToE9u7Z/HZW2KA8Hwc8MCNXoI3uGSIA==} - - '@ark/util@0.56.6': - resolution: {integrity: sha512-mHqq0f0HyDd9YadpFl25w0QECAP7d8HGIUpEC85duD+L+xr4i/CwfOid/Z7zJFFF/47oLt6+pZB9RbqWpBYTYg==} - '@asamuzakjp/css-color@7.1.3': resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1902,11 +1887,6 @@ packages: peerDependencies: style-dictionary: ^5.0.0 - '@tokens-studio/tokenscript-interpreter@0.26.0': - resolution: {integrity: sha512-dGjvUJnXRspWYp98FZw43l4cN+0ey/cF5sEJjL3coKc5C7DY7MsKgkmOONizmaZqf13GUIzklTEas3gt3jvrOQ==} - engines: {node: '>=16.0.0'} - hasBin: true - '@tokens-studio/types@0.5.2': resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==} @@ -2134,12 +2114,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arkregex@0.0.12: - resolution: {integrity: sha512-sOxL0eX50pnRYO4OyajKZChGuuj/iHwC5tysiAaGOPBu+zJwJQckWn7/DRH2++ol5uaKpyRn7N0hKT7nDqGmgg==} - - arktype@2.2.7: - resolution: {integrity: sha512-AbgM5SQvwCTURSR+ENfPtOG7cL5VZ0hqOVtoWyXKw2SviFddZhCBEUAjjVJYKCNFlPPR1VX5LGUKxsSqqc4Kdw==} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3873,9 +3847,6 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@0.2.1: resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} @@ -4174,10 +4145,6 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - readline-sync@1.4.10: - resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} - engines: {node: '>= 0.8.0'} - recast@0.23.21: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} @@ -5203,10 +5170,6 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yauzl@3.4.0: - resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} - engines: {node: '>=12'} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -5215,12 +5178,6 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@ark/schema@0.56.6': - dependencies: - '@ark/util': 0.56.6 - - '@ark/util@0.56.6': {} - '@asamuzakjp/css-color@7.1.3': dependencies: '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) @@ -6461,13 +6418,6 @@ snapshots: is-mergeable-object: 1.1.1 style-dictionary: 5.5.5 - '@tokens-studio/tokenscript-interpreter@0.26.0': - dependencies: - arktype: 2.2.7 - commander: 14.0.3 - readline-sync: 1.4.10 - yauzl: 3.4.0 - '@tokens-studio/types@0.5.2': {} '@tybys/wasm-util@0.10.4': @@ -6717,16 +6667,6 @@ snapshots: aria-query@5.3.2: {} - arkregex@0.0.12: - dependencies: - '@ark/util': 0.56.6 - - arktype@2.2.7: - dependencies: - '@ark/schema': 0.56.6 - '@ark/util': 0.56.6 - arkregex: 0.0.12 - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -8555,8 +8495,6 @@ snapshots: pathval@2.0.1: {} - pend@1.2.0: {} - picocolors@0.2.1: {} picocolors@1.1.1: {} @@ -8897,8 +8835,6 @@ snapshots: readdirp@5.1.1: {} - readline-sync@1.4.10: {} - recast@0.23.21: dependencies: ast-types: 0.16.3 @@ -10018,8 +9954,4 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 - yauzl@3.4.0: - dependencies: - pend: 1.2.0 - yocto-queue@1.2.2: {} diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 9b37274b5f..8021298f2c 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -10,7 +10,6 @@ ["style-dictionary$default" :as sd] [app.common.files.tokens :as cfo] [app.common.logging :as l] - [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] @@ -86,8 +85,7 @@ (let [number? (or (number? value) (numeric-string? value)) parsed-value (cfo/parse-token-value value) - out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) - (<= (:value parsed-value) sm/min-safe-int))] + out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))] (cond (and parsed-value (not out-of-bounds) number?) @@ -112,8 +110,7 @@ If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] (let [parsed-value (cfo/parse-token-value value) - out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) - (<= (:value parsed-value) sm/min-safe-int))] + out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))] (if (and parsed-value (not out-of-bounds)) parsed-value (if out-of-bounds @@ -185,7 +182,7 @@ If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] (let [normalized-value (str/lower (str/trim value)) - valid? (contains? #{"none" "uppercase" "lowercase" "capitalize"} normalized-value) + valid? (contains? cto/text-case-values normalized-value) references (seq (cto/find-token-value-references value))] (cond valid? diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index 7f0cbee92e..f002d49bed 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -1,166 +1,255 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + (ns app.main.data.tokenscript + "Resolves the workspace tokens with the tokenscript interpreter + (`tokenscript.core`, from penpot/tokenscript-interpreter). + + `resolve-tokens` takes the usual map of token name to token and returns + the same map with `:resolved-value` (a value ready for shape attributes: + numbers in pixels, colors as hex strings, composites as maps), + `:resolved-symbol` (the tokenscript value, see + `tokenscript.symbols`) and `:errors` (Penpot error maps, see + `app.main.data.workspace.tokens.errors`) added to each token." (:require - ["@penpot/tokenscript" :refer [BaseSymbolType - ColorSymbol - ListSymbol NumberSymbol - NumberWithUnitSymbol - ProcessorError - processTokens - TokenSymbol - makeConfig]] [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] [app.common.types.token :as cto] [app.main.data.workspace.tokens.errors :as wte] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [tokenscript.core :as tsc] + [tokenscript.errors :as tse] + [tokenscript.symbols :as sym])) -(l/set-level! :debug) +;; Value conversion ----------------------------------------------------------- -;; Config ---------------------------------------------------------------------- +(defn- color->hex-string + "Hex string of a color symbol (`#rrggbbaa` when it has alpha)." + [color] + (sym/to-string (tsc/color->hex color))) -(def config (makeConfig)) +(defn- symbol->value + "Converts a resolved tokenscript value into the shape used by shape + attributes and the token forms: -;; Class predicates ------------------------------------------------------------ -;; Predicates to get information about the tokenscript interpreter symbol type -;; Or to determine the error + - colors: hex strings (alpha as `#rrggbbaa`) + - `rem` values: pixels (16px root font size), `%` values: fractions + - other numbers with unit: the number + - structured tokens (typography, shadows): maps with keyword keys and + vectors + - null: nil" + [v] + (let [entries->map (fn [entries] + (into {} (map (fn [[k v]] [(keyword k) (symbol->value v)])) entries))] + (cond + (nil? v) nil + (sym/null? v) nil + (sym/token? v) (let [content (sym/value v)] + (if (map? content) + (entries->map content) + (mapv symbol->value content))) + (sym/list? v) (mapv symbol->value (sym/value v)) + (sym/dictionary? v) (entries->map (sym/value v)) + (sym/color? v) (color->hex-string v) + (sym/number-with-unit? v) (let [n (sym/value v)] + (case (:unit v) + "rem" (* n 16) + "%" (/ n 100) + n)) + (sym/symbol? v) (sym/value v) + :else v))) -(defn tokenscript-symbol? [v] - (instance? BaseSymbolType v)) +;; Value validation ----------------------------------------------------------- +;; +;; Tokenscript resolves any expression; these are the Penpot level checks +;; on the resolved value of each token type (ranges, allowed keywords, +;; composite shapes) that decide whether a token can be applied. -(defn structured-token? [v] - (instance? TokenSymbol v)) +(defn- words->string + "Values like `700 Italic` resolve to a list of words; font weights are + strings." + [v] + (if (sequential? v) (str/join " " (map str v)) v)) -(defn structured-record-token? [^js v] - (and (structured-token? v) (instance? js/Map (.-value v)))) - -(defn structured-array-token? [^js v] - (and (structured-token? v) (instance? js/Array (.-value v)))) - -(defn number-with-unit-symbol? [v] - (instance? NumberWithUnitSymbol v)) - -(defn number-symbol? [v] - (instance? NumberSymbol v)) - -(defn list-symbol? [v] - (instance? ListSymbol v)) - -(defn color-symbol? [v] - (instance? ColorSymbol v)) - -(defn processor-error? [err] - (instance? ProcessorError err)) - -;; Conversion Tools ------------------------------------------------------------ -;; Helpers to convert tokenscript symbols to penpot accepted formats - -(defn color-symbol->hex-string [^js v] - (when (color-symbol? v) - (.toString (.to v "hex")))) - -(defn color-alpha [^js v] - (if (.isHex v) - 1 - (or (.getAttribute v "alpha") 1))) - -(defn color-symbol->penpot-color [^js v] - {:color (color-symbol->hex-string v) - :opacity (color-alpha v)}) - -(defn rem-number-with-unit? [v] - (and (number-with-unit-symbol? v) - (= (.-unit v) "rem"))) - -(defn percent-number-with-unit? [v] - (and (number-with-unit-symbol? v) - (= (.-unit v) "%"))) - -(defn rem->px [^js v] - (* (.-value v) 16)) - -(declare tokenscript-symbols->penpot-unit) - -(defn font-family-symbols->penpot-unit - "Converts a resolved font-family value into a vector of family names. - Each family name is the string form of its whole entry, which is a list of - word symbols when the family reached tokenscript unquoted." - [^js v] - (when (some? v) - (let [entries (.-value v)] - (if (instance? js/Array entries) - (mapv str entries) - [(str v)])))) - -(defn structured-token->penpot-map - "Converts structured token (record or array) to penpot map format. - Structured tokens are non-primitive token types like `typography` or `box-shadow`." - [^js token-symbol] - (if (instance? js/Array (.-value token-symbol)) - (mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) - (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] - (into {} (map (fn [[k v]] - (let [k (keyword k)] - ;; The font-family member of a composite is a family list - [k (if (= :font-family k) - (font-family-symbols->penpot-unit v) - (tokenscript-symbols->penpot-unit v))]))) - entries)))) - -(defn tokenscript-symbols->penpot-unit [^js v] +(defn- font-family->vector + "Font families are always a flat vector of family names, also when the + value is a single name or references a font family token." + [v] (cond - (nil? v) nil - (structured-token? v) (structured-token->penpot-map v) - (list-symbol? v) (structured-token->penpot-map v) - (color-symbol? v) (.-value (.to v "hex")) - (rem-number-with-unit? v) (rem->px v) - (percent-number-with-unit? v) (/ (.-value v) 100) - :else (.-value v))) + (string? v) [v] + (sequential? v) (vec (flatten v)) + :else v)) -(defn resolved-value->penpot-unit - "Converts the resolved value of a token of the given `type`. - Font families need the type: a list of symbols is a list of families at the - top level and the words of a single family name inside an entry." - [type ^js v] - (if (= :font-family type) - (font-family-symbols->penpot-unit v) - (tokenscript-symbols->penpot-unit v))) +(defn- line-height->ratio + "Line heights in pixels are stored relative to the font size. Returns + the ratio or an error when the font size is not available." + [line-height font-size] + (if (and (sym/number-with-unit? line-height) (= "px" (:unit line-height))) + (if (number? font-size) + {:value (/ (sym/value line-height) font-size)} + {:error (wte/error-with-value :error.style-dictionary/composite-line-height-needs-font-size font-size)}) + {:value (symbol->value line-height)})) -;; Processors ------------------------------------------------------------------ -;; The processor resolves tokens -;; resolved/error tokens get put back into a clojure structure directly during build time -;; For updating tokens we use the `TokenResolver` crud methods from the processing result -;; The `TokenResolver` has detailed information for each tokens dependency graph +(defn- typography->value + "Converts a resolved typography composite, adjusting the fields the + text attributes expect. With `line-height-ratio` a line height in + pixels is converted relative to the font size." + [s line-height-ratio] + (let [value (symbol->value s)] + (if-not (map? value) + {:value value} + (let [value (cond-> value + (contains? value :font-family) (update :font-family font-family->vector) + (contains? value :font-weight) (update :font-weight words->string)) + line-height (when (and line-height-ratio (sym/token? s)) + (get (sym/value s) "line-height"))] + (if (some? line-height) + (let [{ratio :value error :error} (line-height->ratio line-height (:font-size value))] + (if error + {:error error} + {:value (assoc value :line-height ratio)})) + {:value value}))))) -(defn create-token-builder - "Collects resolved tokens during build time into a clojure structure. - Returns Tokenscript Symbols in `:resolved-value` key." - [tokens] - (let [output (volatile! tokens) +(defn- convert-value + "Converts the resolved symbol of a token to the value shape expected by + its type. Returns `{:value v}` or `{:error e}`. Options: - ;; When a token is resolved (No parsing / reference errors) we assing `:resolved-value` for the original token - on-resolve - (fn [^js/String token-name ^js/Symbol resolved-symbol] - (vswap! output assoc-in [token-name :resolved-value] resolved-symbol)) + :line-height-ratio convert typography line heights in pixels to a + ratio of the font size (default true)" + ([type s] (convert-value type s nil)) + ([type s {:keys [line-height-ratio] :or {line-height-ratio true}}] + (case type + :typography (typography->value s line-height-ratio) + :font-family {:value (font-family->vector (symbol->value s))} + :font-weight {:value (words->string (symbol->value s))} + {:value (symbol->value s)}))) - ;; When a token contains any errors we assing `:errors` for the original token - on-error - (fn [^js/String token-name ^js/Error _error ^js/String _original-value] - (let [value (get tokens token-name) - default-error [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]] - (vswap! output assoc-in [token-name :errors] default-error))) +(defn- convert-token-value + "`convert-value` for a token: a failing conversion (e.g. a color that + cannot be written as hex) is an error of that token, not of the whole + resolution." + [{:keys [type value]} s] + (try + (convert-value type s) + (catch :default _ + {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)}))) - ;; Extract the atom value - get-result - (fn [] @output)] - #js {:onResolve on-resolve - :onError on-error - :getResult get-result})) +(defn plugin-value + "Resolved value of a token as exposed by the plugin API: like + `:resolved-value` but with typography line heights left as resolved." + [{:keys [type resolved-symbol]}] + (when (some? resolved-symbol) + (:value (convert-value type resolved-symbol {:line-height-ratio false})))) + +(defn- validate-value + "Returns a Penpot error for the resolved `value` of `token`, or nil when + it is acceptable. The interpreter validates the kind of value of each + type; these are the Penpot specific limits on top." + [{:keys [type]} value] + (if (and (number? value) (cto/number-out-of-bounds? value)) + (wte/error-with-value :error.token/number-too-large value) + (case type + :text-decoration + (when-not (and (string? value) (cto/valid-text-decoration value)) + (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value)) + + :font-weight + (when-not (cto/valid-font-weight-variant (str value)) + (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value)) + + :font-family + (when-not (or (string? value) (and (sequential? value) (every? string? value))) + (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)) + + nil))) + +;; Errors --------------------------------------------------------------------- + +(defn- missing-reference-of + "Missing token name behind an issue, if the root cause is a missing + token." + [{:keys [code data]}] + (case code + :processor/token-not-found (:token-name data) + :interpreter/unknown-reference (:reference data) + :processor/dependency-error (let [cause (:cause data)] + (when (= :processor/token-not-found (:code cause)) + (get-in cause [:data :token-name]))) + nil)) + +(defn- circular? + [{:keys [code data]}] + (or (= :processor/circular-dependency code) + (= :processor/circular-dependency (get-in data [:cause :code])))) + +(defn- issue->error + "Maps a tokenscript issue to a Penpot token error. The interpreter only + reports codes and data; the messages are Penpot's. Codes without a + message of their own are an invalid value." + [{:keys [code data] :as issue} value] + (cond + (circular? issue) + (wte/error-with-value :error.token/circular-reference nil) + + (missing-reference-of issue) + (wte/error-with-value :error.style-dictionary/missing-reference [(missing-reference-of issue)]) + + :else + (case code + (:operations/division-by-zero :functions/division-by-zero) + (wte/error-with-value :error.token/division-by-zero nil) + + :operations/cannot-mix-units + (wte/error-with-value :error.token/incompatible-units (:units data)) + + :operations/cannot-compare-units + (wte/error-with-value :error.token/incompatible-units [(:unit-a data) (:unit-b data)]) + + :interpreter/unknown-function + (wte/error-with-value :error.token/unknown-function (:function-name data)) + + (:functions/argument-out-of-range :functions/invalid-base) + (wte/error-with-value :error.token/function-argument-out-of-range (:function-name data)) + + (wte/error-with-value :error.style-dictionary/invalid-token-value value)))) + +(defn- validation-error + "Maps a failed token type validation of the interpreter (an issue with + an `INVALID__` code) to the Penpot error of the token's + type." + [{:keys [code]} {:keys [type value]}] + (case type + :opacity (wte/error-with-value :error.style-dictionary/invalid-token-value-opacity value) + :stroke-width (wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value) + :text-case (wte/error-with-value :error.style-dictionary/invalid-token-value-text-case value) + :text-decoration (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value) + :font-weight (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value) + :font-family (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value) + :typography (wte/error-with-value :error.style-dictionary/invalid-token-value-typography value) + :shadow (wte/error-with-value :error.style-dictionary/invalid-token-value-shadow value) + (if (= "INVALID_NUMBER_UNIT" code) + (wte/error-with-value :error.style-dictionary/value-with-units value) + (wte/error-with-value :error.style-dictionary/invalid-token-value value)))) + +(defn- token-errors + [issues token] + (->> issues + (keep (fn [issue] + (cond + (tse/error-map? issue) (issue->error issue (:value token)) + (= :tokenscript/issue (:type issue)) (validation-error issue token)))) + (seq))) + +;; Resolution ----------------------------------------------------------------- (defn- quote-font-family - "Quotes a font family name so tokenscript reads it as one string instead of - parsing its words (`Red` would become a color, `2P` a number and a word). - Entries with a token reference or already quoted are kept as they are." + "Quotes a font family name so tokenscript reads it as one string instead + of parsing its words (`Red` would become a color, `2P` a number and a + word). Entries with a token reference or already quoted are kept." [family] (cond (or (not (string? family)) @@ -183,49 +272,86 @@ (mapv quote-font-family value) value)) -(defn- token-value->tokenscript-value - [type value] - (case type - :font-family (quote-font-families value) - :typography (cond-> value - (map? value) (d/update-when :font-family quote-font-families)) - value)) - -(defn clj->token->tokenscript-token - "Convert penpot token into a format that tokenscript can handle." +(defn- token->input + "Token data for the interpreter. Types are passed by their DTCG names, + which the interpreter's bundled token types validate." [{:keys [type value]}] - #js {"$type" (name type) - "$value" (clj->js (token-value->tokenscript-value type value))}) + (cond-> {:value (case type + :font-family (quote-font-families value) + :typography (cond-> value + (map? value) (d/update-when :font-family quote-font-families)) + value)} + (some? type) (assoc :type (get cto/token-type->dtcg-token-type type (name type))))) -(defn clj-tokens->tokenscript-tokens - "Convert penpot map of tokens into tokenscript map structure. - tokenscript accepts a map of [token-name {\"$type\": string, \"$value\": any}]" +(defn- empty-value? + [token] + (nil? (:value token))) + +(defn- resolved-token + "Adds the result of the interpreter to `token`: `:resolved-value` and + `:resolved-symbol` for the resolved symbol `s`, or `:errors` for its + `issues`, a failing conversion or a value Penpot doesn't accept." + [token issues s] + (if-let [errors (token-errors issues token)] + (assoc token :errors (vec errors)) + (let [{:keys [value error]} (convert-token-value token s)] + (if-let [error (or error (validate-value token value))] + (assoc token :errors [error]) + (assoc token + :resolved-value value + :resolved-symbol s))))) + +(defn- resolve-tokens* [tokens] - (let [token-map (js/Map.)] - (doseq [[k token] tokens] - (.set token-map k (clj->token->tokenscript-token token))) - token-map)) + (let [tpoint (ct/tpoint-ms) + inputs (into {} + (comp (remove (fn [[_ token]] (empty-value? token))) + (map (fn [[name token]] [name (token->input token)]))) + tokens) + result (tsc/build inputs) + resolved (:tokens result) + issues (:issues result) + output (reduce-kv + (fn [acc name token] + (assoc acc name + (if (empty-value? token) + (assoc token :errors [(wte/get-error-code :error.token/empty-input)]) + (resolved-token token (get issues name) (get resolved name))))) + {} + tokens)] + (l/dbg :hint "tokenscript/resolve-tokens" :elapsed (tpoint)) + output)) -(defn process-tokens - "Builds tokens using `tokenscript`." +;; Several components resolve the same token maps on every change (the +;; sidebar, the forms and the propagation each compute the active tokens +;; separately), so the results of the last few maps are kept. Entries are +;; matched by identity first and by equality second: comparing two maps +;; of a few hundred tokens is far cheaper than resolving them again. +(def ^:private cache-size 8) + +(defonce ^:private results-cache (volatile! [])) + +(defn- cached-result [tokens] - (let [input (clj-tokens->tokenscript-tokens tokens) - result (processTokens input #js {:config config - :builder (create-token-builder tokens)})] - result)) + (let [entries @results-cache] + (or (some (fn [[t result]] (when (identical? t tokens) result)) entries) + (some (fn [[t result]] (when (= t tokens) result)) entries)))) -(defn update-token +(defn- remember-result + [tokens result] + (vswap! results-cache (fn [entries] (vec (take cache-size (cons [tokens result] entries)))))) + +(defn resolve-tokens + "Resolves `tokens` (a map of token name to token). Returns the tokens + with `:resolved-value` or `:errors` added." + [tokens] + (or (cached-result tokens) + (let [result (resolve-tokens* tokens)] + (remember-result tokens result) + result))) + +(defn resolve-token + "Resolves `token` against `tokens` (the token replaces any token with + the same name). Returns the resolved token." [tokens token] - (let [result (process-tokens tokens) - resolver (.-resolver result)] - (.updateToken resolver #js {:tokenPath (:name token) - :tokenData (clj->token->tokenscript-token token)}))) - -;; Main ------------------------------------------------------------------------ - -(defn resolve-tokens [tokens] - (let [tpoint (ct/tpoint-ms) - result (process-tokens tokens) - elapsed (tpoint)] - (l/dbg :hint "tokenscript/resolve-tokens" :elapsed elapsed) - (.-output result))) + (get (resolve-tokens (assoc tokens (:name token) token)) (:name token))) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 6b667e3631..3ab4427ce6 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -735,9 +735,6 @@ any-variant? (->> shapes vals (some ctk/is-variant?) boolean) resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/resolved-value->penpot-unit (:type token) resolved-value) - resolved-value) tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] (rx/concat diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index a713e76d0f..7112ca8255 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -7,9 +7,7 @@ (ns app.main.data.workspace.tokens.color (:require [app.common.files.tokens :as cfo] - [app.config :as cf] - [app.main.data.tinycolor :as tinycolor] - [app.main.data.tokenscript :as ts])) + [app.main.data.tinycolor :as tinycolor])) (defn color-bullet-color [token-color-value] (when-let [tc (tinycolor/valid-color token-color-value)] @@ -19,8 +17,5 @@ (tinycolor/->hex-string tc)))) (defn resolved-token-bullet-color [{:keys [resolved-value] :as token}] - (if (contains? cf/flags :tokenscript) - (when (and resolved-value (ts/color-symbol? resolved-value)) - (ts/color-symbol->penpot-color resolved-value)) - (when (and resolved-value (cfo/color-token? token)) - (color-bullet-color resolved-value)))) + (when (and resolved-value (cfo/color-token? token)) + (color-bullet-color resolved-value))) diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index bb4d06ded7..daa27111c8 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -57,6 +57,22 @@ {:error/code :error.token/number-too-large :error/fn #(str (tr "errors.tokens.number-too-large" %))} + :error.token/division-by-zero + {:error/code :error.token/division-by-zero + :error/fn #(tr "errors.tokens.division-by-zero")} + + :error.token/incompatible-units + {:error/code :error.token/incompatible-units + :error/fn #(tr "errors.tokens.incompatible-units" (str/join ", " %))} + + :error.token/unknown-function + {:error/code :error.token/unknown-function + :error/fn #(tr "errors.tokens.unknown-function" %)} + + :error.token/function-argument-out-of-range + {:error/code :error.token/function-argument-out-of-range + :error/fn #(tr "errors.tokens.function-argument-out-of-range" %)} + ;; Surfaced when a token name conflicts with a token-group prefix ;; across the active sets (e.g. one set defines `a`, another set ;; defines `a.b`). DTCG/StyleDictionary cannot represent both as diff --git a/frontend/src/app/main/data/workspace/tokens/format.cljs b/frontend/src/app/main/data/workspace/tokens/format.cljs index ada74c1df8..759399c3d1 100644 --- a/frontend/src/app/main/data/workspace/tokens/format.cljs +++ b/frontend/src/app/main/data/workspace/tokens/format.cljs @@ -1,6 +1,11 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + (ns app.main.data.workspace.tokens.format (:require - [app.main.data.tokenscript :as ts] [cuerdas.core :as str])) (def category-dictionary @@ -35,39 +40,10 @@ (str/join "\n") (str "\n"))) -(defn- format-structured-token - "Formats tokenscript Token" - [token-symbol] - (->> (.-value token-symbol) - (.entries) - (es6-iterator-seq) - (format-map-entries))) - -(defn format-tokenscript-symbol - [^js tokenscript-symbol] - (cond - (ts/rem-number-with-unit? tokenscript-symbol) - (str (ts/rem->px tokenscript-symbol) "px") - - (ts/color-symbol? tokenscript-symbol) - (ts/color-symbol->hex-string tokenscript-symbol) - - (ts/structured-record-token? tokenscript-symbol) - (format-structured-token tokenscript-symbol) - - (ts/structured-array-token? tokenscript-symbol) - (str/join "\n" (map format-tokenscript-symbol (.-value tokenscript-symbol))) - - :else - (.toString tokenscript-symbol))) - (defn format-token-value "Converts token value of any shape to a string." [token-value] (cond - (ts/tokenscript-symbol? token-value) - (format-tokenscript-symbol token-value) - (map? token-value) (format-map-entries token-value) diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index aa43061336..2027e5be42 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -6,7 +6,6 @@ (ns app.main.data.workspace.tokens.propagation (:require - [app.common.data :as d] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -217,9 +216,7 @@ (when (and tokens-lib tokens-status) (when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)] (->> (if (contains? cf/flags :tokenscript) - (rx/of (-> (ts/resolve-tokens tokens-tree) - (d/update-vals #(update % :resolved-value - (partial ts/resolved-value->penpot-unit (:type %)))))) + (rx/of (ts/resolve-tokens tokens-tree)) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 1e8d9a6198..477d7327ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -81,10 +81,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (first errors)}))))))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index b3dbec0c94..a98e51ce74 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -58,10 +58,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 48d8586565..7833bd95ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -76,10 +76,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/resolved-value->penpot-unit (:type token) resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index 8264836980..b7dd512b7f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -144,16 +144,25 @@ (defn- resolve-value-tokenscript - [tokens prev-token value] - (let [result (ts/update-token tokens (assoc prev-token :value value)) - token-result (.-resolved result)] + [tokens prev-token token-name value] + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token + (assoc prev-token + :value value + :name (if (or (not valid-token-name?) (str/blank? token-name)) + "__PENPOT__TOKEN__NAME__PLACEHOLDER__" + token-name)) + + {:keys [errors resolved-value]} + ;; Remove previous token when renaming a token + (ts/resolve-token (dissoc tokens (:name prev-token)) token)] (rx/of - (cond - (ts/processor-error? token-result) {:error (wte/error-with-value :error.style-dictionary/missing-reference (some->> (.-dependencyChain token-result) - (seq) - (rest)))} - (instance? js/Error token-result) {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)} - :else {:value token-result})))) + (if (seq errors) + {:error (first errors)} + {:value resolved-value})))) (defn- resolve-value [tokens prev-token token-name value] @@ -178,10 +187,7 @@ (sd/resolve-tokens-interactive) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index 428d2fee31..717e37f831 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -44,10 +44,7 @@ (sd/resolve-tokens-interactive tokens')) (rx/mapcat (fn [resolved-tokens] - (let [resolved-token (cond-> (get resolved-tokens (:name token)) - (contains? cf/flags :tokenscript) - (update :resolved-value - (partial ts/resolved-value->penpot-unit (:type token))))] + (let [resolved-token (get resolved-tokens (:name token))] (cond (:resolved-value resolved-token) (rx/of resolved-token) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index cdb8d89284..d98c481028 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -111,7 +111,7 @@ (defn- generate-tooltip "Generates a tooltip for a given token" - [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set is-name-collision errors] + [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set error-message] (let [{:keys [name type resolved-value value]} token resolved-value-theme (:resolved-value theme-token) resolved-value (or resolved-value-theme resolved-value) @@ -141,8 +141,8 @@ ref-not-in-active-set (tr "workspace.tokens.ref-not-valid" name) - is-name-collision - (wte/resolve-error-message (first errors)) + error-message + error-message no-valid-value (tr "workspace.tokens.value-not-valid") @@ -237,9 +237,11 @@ is-viewer? (not can-edit) + tokenscript? (contains? cf/flags :tokenscript) + ref-not-in-active-set - (if (contains? cf/flags :tokenscript) - (seq (:errors resolved-token)) + (if tokenscript? + (wte/has-error-code? :error.style-dictionary/missing-reference errors) (and is-reference? (not (contains-reference-value? value active-theme-tokens)))) @@ -250,6 +252,11 @@ no-valid-value (seq errors) + ;; Tokenscript errors carry their own message, as in the edit form. + error-message + (when (and no-valid-value (or tokenscript? name-collision)) + (wte/resolve-error-message (first errors))) + errors? (or ref-not-in-active-set no-valid-value @@ -297,11 +304,11 @@ on-hover (mf/use-fn - (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set name-collision errors) + (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set error-message) (fn [_] (let [theme-token (get active-theme-tokens name) title (generate-tooltip is-viewer? (first selected-shapes) theme-token token - half-applied? no-valid-value ref-not-in-active-set name-collision errors)] + half-applied? no-valid-value ref-not-in-active-set error-message)] (reset! tooltip-content* title)))) ;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped @@ -346,9 +353,8 @@ [:> icon* {:icon-id i/broken-link :class (stl/css :token-pill-icon) - :aria-label (if name-collision - (wte/resolve-error-message (first errors)) - (tr "workspace.tokens.missing-reference"))}] + :aria-label (or error-message + (tr "workspace.tokens.missing-reference"))}] color [:> swatch* {:background color diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index a558870f95..737099002e 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -232,14 +232,12 @@ k)) (defn- shadow-entry->js - "Converts one resolved shadow entry (a JS Map of field name -> tokenscript - symbol) into a plain JS object using the public member names and the - unit-converted values." - [^js m] + "Converts one resolved shadow entry (a map of field name -> value) into + a plain JS object using the public member names." + [m] (let [out #js {}] - (.forEach m (fn [sym k] - (obj/set! out (shadow-key->camel k) - (ts/tokenscript-symbols->penpot-unit sym)))) + (doseq [[k v] m] + (obj/set! out (shadow-key->camel (name k)) (clj->js v))) out)) (defn- shadow-resolved-value->js @@ -249,38 +247,29 @@ (when (some? entries) (into-array (map shadow-entry->js entries)))) -(defn- font-families-resolved-value->js - "Converts a resolved fontFamilies value (a tokenscript list symbol) into the - documented `string[]` shape." - [resolved-value] - (some-> (ts/font-family-symbols->penpot-unit resolved-value) - (clj->js))) - (defn- get-resolved-value [token tokens-tree] (let [resolved-tokens (ts/resolve-tokens tokens-tree) - resolved-value (dm/get-in resolved-tokens [(:name token) :resolved-value])] + resolved-token (get resolved-tokens (:name token)) + resolved-value (:resolved-value resolved-token)] (cond (= :font-family (:type token)) - ;; A fontFamilies token resolves to a list of families; expose it as the - ;; documented `string[]` rather than the raw tokenscript list symbol. - (font-families-resolved-value->js resolved-value) + ;; A fontFamilies token resolves to a vector of families; expose it as + ;; the documented `string[]`. + (some-> resolved-value clj->js) (= :typography (:type token)) ;; A typography token resolves to a composite; expose it as the documented - ;; `TokenTypographyValue[]` rather than the raw tokenscript structure. - (typography-resolved-value->js - (ts/tokenscript-symbols->penpot-unit resolved-value)) + ;; `TokenTypographyValue[]` with the line height as resolved. + (typography-resolved-value->js (ts/plugin-value resolved-token)) (= :shadow (:type token)) - ;; A shadow token resolves to a list of composites whose entries the - ;; tokenscript unit conversion leaves as raw symbols; expose them as the + ;; A shadow token resolves to a list of composites; expose them as the ;; documented `TokenShadowValue[]`. - (shadow-resolved-value->js - (ts/tokenscript-symbols->penpot-unit resolved-value)) + (shadow-resolved-value->js resolved-value) :else - (ts/tokenscript-symbols->penpot-unit resolved-value)))) + resolved-value))) (defn token-proxy? [p] (obj/type-of? p "TokenProxy")) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 28a0028593..5dd9553f7d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -110,6 +110,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.token-path-test] + [frontend-tests.tokens.tokenscript-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] [frontend-tests.ui.color-token-position-test] @@ -269,6 +270,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.token-path-test + 'frontend-tests.tokens.tokenscript-test 'frontend-tests.tokens.logic.tokens-status-test 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.check-updates-test diff --git a/frontend/test/frontend_tests/tokens/tokenscript_test.cljs b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs new file mode 100644 index 0000000000..f6c1b8e01d --- /dev/null +++ b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs @@ -0,0 +1,184 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.tokenscript-test + (:require + [app.common.test-helpers.ids-map :as cthi] + [app.common.types.tokens-lib :as ctob] + [app.main.data.tokenscript :as ts] + [cljs.test :as t :include-macros true])) + +(defn- tokens-map + "Tokens map of a library with one set holding `tokens`." + [& tokens] + (let [lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) :name "core")))] + (-> (reduce (fn [lib token] (ctob/add-token lib (cthi/id :core-set) (ctob/make-token token))) lib tokens) + (ctob/get-all-tokens-map)))) + +(t/deftest resolve-tokens-test + (t/testing "resolves references and converts units to pixels" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "borderRadius.sm" :value "12px" :type :border-radius} + {:name "borderRadius.md-with-dashes" :value "{borderRadius.sm} * 2" :type :border-radius} + {:name "borderRadius.rem" :value "2rem" :type :border-radius} + {:name "opacity.half" :value "50%" :type :opacity} + {:name "borderRadius.large" :value "123456789012345" :type :border-radius} + {:name "borderRadius.largePx" :value "123456789012345px" :type :border-radius} + {:name "borderRadius.largeFn" :value "{borderRadius.sm} * 200000000" :type :border-radius}))] + (t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value]))) + (t/is (= 24 (get-in resolved ["borderRadius.md-with-dashes" :resolved-value]))) + (t/is (= 32 (get-in resolved ["borderRadius.rem" :resolved-value]))) + (t/is (= 0.5 (get-in resolved ["opacity.half" :resolved-value]))) + (doseq [name ["borderRadius.large" "borderRadius.largePx" "borderRadius.largeFn"]] + (t/is (nil? (get-in resolved [name :resolved-value]))) + (t/is (= :error.token/number-too-large (get-in resolved [name :errors 0 :error/code]))))))) + +(t/deftest resolve-tokens-colors-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "color.red" :value "#ff0000" :type :color} + {:name "color.alias" :value "{color.red}" :type :color} + {:name "color.alpha" :value "#ff000080" :type :color} + {:name "color.named" :value "red" :type :color} + {:name "color.light" :value "lighten({color.red}, 0.2)" :type :color}))] + (t/is (= "#ff0000" (get-in resolved ["color.alias" :resolved-value]))) + (t/is (= "#ff000080" (get-in resolved ["color.alpha" :resolved-value])) "alpha is kept as hex8") + (t/is (= "#FF0000" (get-in resolved ["color.named" :resolved-value])) "css names resolve") + (t/is (string? (get-in resolved ["color.light" :resolved-value])) "color functions resolve to hex"))) + +(t/deftest resolve-tokens-errors-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "a" :value "{missing}" :type :border-radius} + {:name "b" :value "{a} + 1" :type :border-radius} + {:name "x" :value "{y}" :type :border-radius} + {:name "y" :value "{x}" :type :border-radius} + {:name "z" :value "{x} + 1" :type :border-radius} + {:name "bad" :value "1 ´ 2" :type :border-radius}))] + (t/is (= :error.style-dictionary/missing-reference (get-in resolved ["a" :errors 0 :error/code]))) + (t/is (= ["missing"] (get-in resolved ["a" :errors 0 :error/value]))) + (t/is (= :error.style-dictionary/missing-reference (get-in resolved ["b" :errors 0 :error/code])) + "dependents report the missing token") + (t/is (= ["missing"] (get-in resolved ["b" :errors 0 :error/value]))) + (t/is (= :error.token/circular-reference (get-in resolved ["x" :errors 0 :error/code]))) + (t/is (= :error.token/circular-reference (get-in resolved ["y" :errors 0 :error/code]))) + (t/is (= :error.token/circular-reference (get-in resolved ["z" :errors 0 :error/code])) + "dependents of a cycle report the cycle") + (t/is (= :error.style-dictionary/invalid-token-value (get-in resolved ["bad" :errors 0 :error/code]))))) + +(t/deftest resolve-tokens-error-codes-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "zero" :value "1 / 0" :type :number} + {:name "mod-zero" :value "mod(4, 0)" :type :number} + {:name "units" :value "1px * 2em" :type :sizing} + {:name "fn" :value "foo(1)" :type :number} + {:name "ln" :value "ln(0)" :type :number} + {:name "ref" :value "{zero} + 1" :type :number})) + error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))] + (t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "zero"))) + (t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "mod-zero"))) + (t/is (= {:error/code :error.token/incompatible-units :error/value ["px" "em"]} (error "units"))) + (t/is (= {:error/code :error.token/unknown-function :error/value "foo"} (error "fn"))) + (t/is (= {:error/code :error.token/function-argument-out-of-range :error/value "ln"} (error "ln"))) + (t/is (= {:error/code :error.style-dictionary/invalid-token-value :error/value "{zero} + 1"} (error "ref")) + "a token failing through its reference is an invalid value"))) + +;; A token named "a" and a token named "a.b" can coexist: `{a}` resolves +;; to the token, `{a.b}` to the nested one (#9584). +(t/deftest resolve-tokens-name-collision-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "a" :value "8px" :type :border-radius} + {:name "a.b" :value "12px" :type :border-radius} + {:name "c" :value "{a} + {a.b}" :type :border-radius}))] + (t/is (= 8 (get-in resolved ["a" :resolved-value]))) + (t/is (= 12 (get-in resolved ["a.b" :resolved-value]))) + (t/is (= 20 (get-in resolved ["c" :resolved-value]))))) + +(t/deftest resolve-tokens-composites-test + (t/testing "typography and shadow tokens resolve field by field" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "size" :value "16" :type :font-size} + {:name "typo" :type :typography + :value {:font-family ["Roboto" "Arial"] + :font-size "{size} * 2" + :font-weight "700 Italic" + :line-height "48px"}} + {:name "shadow" :type :shadow + :value [{:offset-x "2" :offset-y "{size}" :blur "4" :spread "0" + :color "#000" :inset false}]}))] + (t/is (= {:font-family ["Roboto" "Arial"] :font-size 32 :font-weight "700 Italic" :line-height 1.5} + (get-in resolved ["typo" :resolved-value])) + "line heights in pixels become a ratio of the font size") + (t/is (= [{:offset-x 2 :offset-y 16 :blur 4 :spread 0 :color "#000" :inset false}] + (get-in resolved ["shadow" :resolved-value]))))) + (t/testing "font families referencing a font family token are flattened" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "families" :value ["Arial" "sans-serif"] :type :font-family} + {:name "single" :value "Roboto" :type :font-family} + {:name "typo" :value {:font-family ["{families}"]} :type :typography}))] + (t/is (= ["Arial" "sans-serif"] (get-in resolved ["families" :resolved-value]))) + (t/is (= ["Roboto"] (get-in resolved ["single" :resolved-value]))) + (t/is (= ["Arial" "sans-serif"] (get-in resolved ["typo" :resolved-value :font-family]))))) + (t/testing "a typography token with an array value is invalid" + (let [resolved (ts/resolve-tokens (tokens-map {:name "typography.bad" :value ["Roboto"] :type :typography}))] + (t/is (nil? (get-in resolved ["typography.bad" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-typography + (get-in resolved ["typography.bad" :errors 0 :error/code])))))) + +(t/deftest resolve-tokens-value-validation-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "opacity.bad" :value "2" :type :opacity} + {:name "stroke.bad" :value "-1" :type :stroke-width} + {:name "case.ok" :value "uppercase" :type :text-case} + {:name "case.bad" :value "wat" :type :text-case} + {:name "weight.ok" :value "bold" :type :font-weight} + {:name "weight.bad" :value "heavyish" :type :font-weight}))] + (t/is (= :error.style-dictionary/invalid-token-value-opacity (get-in resolved ["opacity.bad" :errors 0 :error/code]))) + (t/is (= :error.style-dictionary/invalid-token-value-stroke-width (get-in resolved ["stroke.bad" :errors 0 :error/code]))) + (t/is (= "uppercase" (get-in resolved ["case.ok" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-text-case (get-in resolved ["case.bad" :errors 0 :error/code]))) + (t/is (= "bold" (get-in resolved ["weight.ok" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-font-weight (get-in resolved ["weight.bad" :errors 0 :error/code]))))) + +;; The interpreter validates the kind of value of each token type: `1foo` +;; and `1 2` are lists, not numbers. +(t/deftest resolve-tokens-type-validation-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "size.glued" :value "1foo" :type :dimensions} + {:name "size.list" :value "1 2" :type :sizing} + {:name "size.ok" :value "1rem" :type :sizing} + {:name "number.glued" :value "1foo" :type :number} + {:name "number.unit" :value "10px" :type :number} + {:name "number.ok" :value "1.5" :type :number} + {:name "opacity.percent" :value "150%" :type :opacity} + {:name "typography.bad" :value {:font-size "1foo" :line-height "1.5"} :type :typography} + {:name "shadow.bad" :value "8" :type :shadow})) + code (fn [name] (get-in resolved [name :errors 0 :error/code]))] + (doseq [name ["size.glued" "size.list" "number.glued"]] + (t/is (nil? (get-in resolved [name :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value (code name)))) + (t/is (= "1foo" (get-in resolved ["size.glued" :errors 0 :error/value]))) + (t/is (= :error.style-dictionary/value-with-units (code "number.unit"))) + (t/is (= :error.style-dictionary/invalid-token-value-opacity (code "opacity.percent"))) + (t/is (= :error.style-dictionary/invalid-token-value-typography (code "typography.bad"))) + (t/is (= :error.style-dictionary/invalid-token-value-shadow (code "shadow.bad"))) + (t/is (= 16 (get-in resolved ["size.ok" :resolved-value]))) + (t/is (= 1.5 (get-in resolved ["number.ok" :resolved-value]))))) + +;; A token with a `nil` value (e.g. a composite typography token saved via +;; the workspace form with no fields filled in) is tagged with an empty +;; input error and does not affect the other tokens. +(t/deftest resolve-tokens-nil-value-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "typography.empty" :value nil :type :typography} + {:name "borderRadius.sm" :value "12px" :type :border-radius}))] + (t/is (nil? (get-in resolved ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input (get-in resolved ["typography.empty" :errors 0 :error/code]))) + (t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value]))))) + +(t/deftest resolve-token-test + (let [tokens (tokens-map {:name "a" :value "8px" :type :border-radius}) + resolved (ts/resolve-token tokens {:name "b" :value "{a} * 2" :type :border-radius})] + (t/is (= 16 (:resolved-value resolved))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 040339f8ef..5c22390bbd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1805,6 +1805,10 @@ msgstr "Token has circular reference" msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved value." +#: src/app/main/data/workspace/tokens/errors.cljs:62 +msgid "errors.tokens.division-by-zero" +msgstr "Invalid token value: division by zero" + #: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 msgid "errors.tokens.empty-field" msgstr "This field cannot be empty" @@ -1817,10 +1821,18 @@ msgstr "Token value cannot be empty" msgid "errors.tokens.error-parse" msgstr "Import Error: Could not parse JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:74 +msgid "errors.tokens.function-argument-out-of-range" +msgstr "Invalid token value: the argument of %s() is out of range" + #: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 msgid "errors.tokens.import-error" msgstr "Import Error:" +#: src/app/main/data/workspace/tokens/errors.cljs:66 +msgid "errors.tokens.incompatible-units" +msgstr "Invalid token value: the units %s cannot be combined" + #: src/app/main/data/workspace/tokens/errors.cljs:54 msgid "errors.tokens.invalid-color" msgstr "Invalid color value: %s" @@ -1914,6 +1926,10 @@ msgstr "Shadow spread must be greater than or equal to 0." msgid "errors.tokens.stroke-width-range" msgstr "Stroke width must be greater than or equal to 0." +#: src/app/main/data/workspace/tokens/errors.cljs:70 +msgid "errors.tokens.unknown-function" +msgstr "Invalid token value: unknown function %s" + #: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 msgid "errors.tokens.value-with-percent" msgstr "Invalid value: % is not allowed." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index e369ed0b12..4900d82796 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1837,6 +1837,10 @@ msgstr "" "El Line Height depende del Font Size. Añade un Font Size para obtener el " "valor computado." +#: src/app/main/data/workspace/tokens/errors.cljs:62 +msgid "errors.tokens.division-by-zero" +msgstr "Valor de token no válido: división por cero" + #: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 msgid "errors.tokens.empty-field" msgstr "Este campo no puede estar vacío" @@ -1849,10 +1853,18 @@ msgstr "El valor del token no puede estar vacío" msgid "errors.tokens.error-parse" msgstr "Error al importar: No se pudo procesar el JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:74 +msgid "errors.tokens.function-argument-out-of-range" +msgstr "Valor de token no válido: el argumento de %s() está fuera de rango" + #: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 msgid "errors.tokens.import-error" msgstr "Error al importar:" +#: src/app/main/data/workspace/tokens/errors.cljs:66 +msgid "errors.tokens.incompatible-units" +msgstr "Valor de token no válido: las unidades %s no se pueden combinar" + #: src/app/main/data/workspace/tokens/errors.cljs:54 msgid "errors.tokens.invalid-color" msgstr "Valor de color inválido: %s" @@ -1947,6 +1959,10 @@ msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0." msgid "errors.tokens.stroke-width-range" msgstr "Stroke width debe ser mayor o igual a 0." +#: src/app/main/data/workspace/tokens/errors.cljs:70 +msgid "errors.tokens.unknown-function" +msgstr "Valor de token no válido: función desconocida %s" + #: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 msgid "errors.tokens.value-with-percent" msgstr "Valor no válido: no se permite %." From e27c65c77a9430891469b462472483bddc8b62b3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Oct 2026 11:02:23 +0200 Subject: [PATCH 137/325] :sparkles: Run WASM asset exports locally in the browser (#12515) When render-wasm is active, shape exports (single file, ZIP, and Export boards multi-page PDF) run on the shared worker WASM module so the UI stays responsive. A progressive PDF session (begin/add-page/finish) updates progress per page; cancel settles on the client. The server exporter path remains for the SVG renderer. --- .../src/app/main/data/exports/assets.cljs | 377 ++++++++++++------ frontend/src/app/main/data/exports/wasm.cljs | 88 +++- frontend/src/app/main/ui/exports/assets.cljs | 13 +- frontend/src/app/render_wasm/api.cljs | 114 +++++- frontend/src/app/worker.cljs | 1 + frontend/src/app/worker/exports.cljs | 260 ++++++++++++ frontend/src/app/worker/messages.cljs | 70 +++- frontend/src/app/worker/thumbnails.cljs | 44 +- frontend/src/app/worker/wasm.cljs | 87 ++++ .../data/exports_assets_test.cljs | 253 +++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/worker/exports_test.cljs | 70 ++++ render-wasm/src/main.rs | 63 +++ render-wasm/src/render/pdf.rs | 311 +++++++++++++-- render-wasm/src/render/pdf/glyph_text.rs | 19 + render-wasm/src/state.rs | 23 ++ 16 files changed, 1554 insertions(+), 241 deletions(-) create mode 100644 frontend/src/app/worker/exports.cljs create mode 100644 frontend/src/app/worker/wasm.cljs create mode 100644 frontend/test/frontend_tests/worker/exports_test.cljs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 5ca4f3e983..e85c1c3cd3 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -6,10 +6,10 @@ (ns app.main.data.exports.assets (:require + [app.common.files.helpers :as cfh] [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.event :as ev] - [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] @@ -18,9 +18,11 @@ [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] + [app.main.worker :as mw] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.i18n :refer [tr]] + [app.util.webapi :as wapi] [app.util.websocket :as ws] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -236,17 +238,22 @@ (update [_ state] (let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now)) healthy? (< time-diff 6000) - ;; The legacy path has no server-side figures to track; it keeps - ;; reporting progress over the client's own list. - job? (some? (get-in state [:export :job-id]))] + ;; Local WASM jobs also store a client job-id so the widget can + ;; show cancelling/cancelled (it keys those labels on job-id). + job? (some? (get-in state [:export :job-id])) + current (get-in state [:export :status]) + ;; Cancel settles the widget immediately; a late worker + ;; `:progress` must not flip status back to "running". + terminal? (contains? #{"cancelled" "cancelling" "ended" "error"} current) + apply? (or (not= status "running") (not terminal?))] (cond-> state - job? + (and job? apply?) (update :export assoc :status status) - (and job? (some? total)) + (and job? apply? (some? total)) (update :export assoc :total total) - (= status "running") + (and apply? (= status "running")) (update :export assoc :progress done :last-update (ct/now) :healthy? healthy?) (= status "error") @@ -266,24 +273,37 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) -(defn cancel-export - "Stops the running export. Only reachable on the job API path, where the - exporter can actually abort the work. +(def ^:private local-export-job-id* + "Job id of the in-flight worker multi-export, if any." + (atom nil)) - The widget settles from here rather than from the job's `cancelled` message: - the outcome is known once the request returns, and waiting on a round trip - through redis and the websocket would leave it stuck whenever that message is - missed." +(defn cancel-export + "Stops the running export. On the job API path the exporter aborts; on the + local WASM path the worker is told to stop and the widget settles here. + + Local cancel settles on the client: if the worker is stuck before the first + cancel check, waiting for a `:cancelled` reply would leave the UI hung." [] (ptk/reify ::cancel-export ptk/WatchEvent (watch [_ state _] - (when-let [job-id (get-in state [:export :job-id])] - (let [resource-id (get-in state [:export :resource-id]) - settle (rx/concat - (rx/of (update-export-status {:status "cancelled"})) - (->> (rx/of (clear-export-state resource-id)) - (rx/delay default-timeout)))] + (let [backend (get-in state [:export :backend]) + job-id (get-in state [:export :job-id]) + resource-id (get-in state [:export :resource-id]) + settle (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout)))] + (cond + (= "local" backend) + (do + (when-let [local-job-id @local-export-job-id*] + (mw/emit! {:cmd :exports/cancel :job-id local-job-id})) + ;; Mark cancelled so a late `:done` from the worker is ignored. + (reset! local-export-job-id* nil) + settle) + + (some? job-id) (rx/concat ;; Stopping is not instantaneous: the request has to reach the ;; exporter and the work has to unwind. @@ -302,6 +322,13 @@ (features/active-feature? state "render-wasm/v1")) (defn- export-target + "Where a single-shape export runs. + + With render-wasm off → `:exporter` (server). + With render-wasm on → never the server API: always `:client` when the + format is supported, except true `:too-large` (beyond raster limits — + the exporter would fail too). Oversized-for-GPU rasters still run + locally via the CPU path in `export-bytes`." [state {:keys [type object-id scale]}] (cond (not (and (wasm-export-enabled? state) @@ -311,18 +338,14 @@ (contains? #{:pdf :svg} type) :client + ;; Raster: only refuse when WASM reports true `:too-large`. The legacy + ;; `:exporter` code means “use CPU raster” — we do that locally. :else - (or (wasm.api/export-target object-id (or scale 1) type) :exporter))) + (if (= :too-large (wasm.api/export-target object-id (or scale 1) type)) + :too-large + :client))) -(defn- request-simple-export-wasm - [export] - (ptk/reify ::request-simple-export-wasm - ptk/EffectEvent - (effect [_ _ _] - (case (:type export) - :pdf (wasm.exports/export-pdf export) - :svg (wasm.exports/export-svg export) - (wasm.exports/export-image export))))) +(declare request-local-export-wasm) (defn request-simple-export [{:keys [export]}] @@ -340,7 +363,10 @@ (watch [_ state _] (case @target :client - (rx/of (request-simple-export-wasm export)) + (rx/of (request-local-export-wasm {:exports [export] + :cmd :export-shapes + :name (:name export) + :kind :single})) :too-large (rx/of (ntf/error (tr "errors.export-too-large"))) @@ -363,95 +389,218 @@ (rx/of (clear-export-state uuid/zero) (export-failed cause)))))))))))) -(defn request-multiple-export +(defn- export-file-page-ids + [state exports] + (let [file-id (or (:file-id (first exports)) (:current-file-id state)) + page-id (or (:page-id (first exports)) (:current-page-id state))] + [file-id page-id])) + +(defn collect-export-objects + "Live page objects needed for `exports`: each root plus its descendants. + Falls back to the full page when the closure is empty (missing ids)." + [state exports] + (let [[file-id page-id] (export-file-page-ids state exports) + objects (or (dsh/lookup-page-objects state file-id page-id) {}) + root-ids (into #{} (map :object-id) exports) + ids (into #{} + (mapcat #(cfh/get-children-ids-with-self objects %)) + root-ids) + selected (select-keys objects ids)] + (if (seq selected) + selected + objects))) + +(defn- export-page-background + [state exports] + (let [[file-id page-id] (export-file-page-ids state exports) + page (dsh/lookup-page state file-id page-id)] + (:background page))) + +(defn- worker-export-entries + "Payload for the worker: drop `:shape` and other UI-only keys." + [exports] + (mapv (fn [{:keys [object-id type scale name suffix]}] + {:object-id object-id + :type type + :scale (or scale 1) + :name name + :suffix suffix}) + exports)) + +(defn- request-local-export-wasm + "Client-side export on the shared worker WASM module: one file (`:single`), + a ZIP of shapes (`:zip`), or a multi-page boards PDF (`:pdf-frames`)." + [{:keys [exports cmd name kind] + :or {cmd :export-shapes}}] + (ptk/reify ::request-local-export-wasm + ptk/WatchEvent + (watch [_ state _] + (let [resource-id (uuid/next) + job-id (uuid/next) + total (count exports) + kind (or kind + (if (= cmd :export-frames) :pdf-frames :zip)) + objects (collect-export-objects state exports) + background (export-page-background state exports) + entries (worker-export-entries exports) + clear-later (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout))] + (reset! local-export-job-id* job-id) + (swap! st/ongoing-tasks conj :export) + + (rx/concat + (rx/of (initialize-export-status exports cmd + {:id resource-id} + {:job-id job-id + :total total + :status "running" + :backend "local"})) + (->> (mw/ask-many! + {:cmd :exports/run-local + :job-id job-id + :kind kind + :exports entries + :objects objects + :background background + :name name}) + (rx/mapcat + (fn [{:keys [type] :as message}] + ;; Cancel clears `local-export-job-id*` and settles the + ;; widget locally. Ignore every later worker message so a + ;; stray `:progress` cannot flip the UI back to "running". + (if (nil? @local-export-job-id*) + (rx/empty) + (case type + :progress + (rx/of (update-export-status {:status "running" + :done (:done message) + :total (:total message)})) + + :done + (let [uri (wapi/create-uri (:blob message))] + (rx/concat + (rx/of (update-export-status + {:status "ended" + :done total + :filename (:filename message) + :mtype (:mtype message) + :resource-uri uri})) + clear-later)) + + :cancelled + (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + clear-later) + + :error + (rx/of (export-widget-failed + exports cmd + (ex-info (or (:hint message) "worker export failed") + (or (:data message) + {:type :internal + :code :worker-export-error})))) + + (rx/empty))))) + (rx/catch + (fn [cause] + (if (= :export-cancelled (:type (ex-data cause))) + (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + clear-later) + (rx/of (export-widget-failed exports cmd cause))))) + (rx/finalize + (fn [] + (reset! local-export-job-id* nil) + (swap! st/ongoing-tasks disj :export))))))))) + +(defn- request-multiple-export-server [{:keys [exports cmd name] + :or {cmd :export-shapes}}] + (ptk/reify ::request-multiple-export-server + ptk/WatchEvent + (watch [_ state _] + (let [resource-id (volatile! nil) + ;; Emits when the export request fails. + failed-s (rx/subject) + profile-id (:profile-id state) + ws-conn (:ws-conn state) + params (cond-> + {:exports exports + :cmd cmd + :profile-id profile-id + :force-multiple true + :is-wasm (wasm-export-enabled? state)} + (some? name) + (assoc :name name)) + + progress-stream + (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/message-event?) + (rx/map :payload) + (rx/filter #(= :export-update (:type %))) + (rx/filter #(= @resource-id (:resource-id %))) + (rx/take-until failed-s) + (rx/share)) + + stopper + (rx/filter #(or (= "ended" (:status %)) + (= "error" (:status %)) + (= "cancelled" (:status %))) + progress-stream) + + file-ids + (export-file-ids exports)] + + (swap! st/ongoing-tasks conj :export) + + (rx/concat + (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) + (rx/of (warn-unsaved-changes file-ids)) + (rx/merge + ;; Updates the export state with each progress message, until + ;; shortly after the export ends or fails. + (->> progress-stream + (rx/map update-export-status) + (rx/take-until (rx/delay 500 stopper)) + (rx/finalize (fn [] + (swap! st/ongoing-tasks disj :export)))) + + ;; Clears the export state a while after the export ends or is + ;; cancelled. + (->> progress-stream + (rx/filter #(or (= "ended" (:status %)) + (= "cancelled" (:status %)))) + (rx/take 1) + (rx/delay default-timeout) + (rx/map #(clear-export-state @resource-id)) + (rx/take-until (rx/delay 6000 stopper))) + + ;; Launches the exportation process and stores the resource id + ;; locally. Goes last so a failure reaches the progress listeners + ;; above: the push to `failed-s` releases the leave-page warning + ;; even though no progress message will ever arrive, and nothing + ;; is raised to the global error handler, which would replace the + ;; workspace with an error page. + (->> (rp/cmd! :export params) + (rx/map (fn [{:keys [id] :as resource}] + (vreset! resource-id id) + (initialize-export-status exports cmd resource nil))) + (rx/catch (fn [cause] + (rx/push! failed-s cause) + (rx/of (export-widget-failed exports cmd cause))))))))))) + +(defn request-multiple-export + [{:keys [exports cmd] :or {cmd :export-shapes} :as params}] - (let [exports (normalize-exports exports)] + (let [exports (normalize-exports exports) + params (assoc params :exports exports :cmd cmd)] (ptk/reify ::request-multiple-export ptk/WatchEvent (watch [_ state _] - (let [resource-id (volatile! nil) - ;; Emits when the export request fails. - failed-s (rx/subject) - profile-id (:profile-id state) - ws-conn (:ws-conn state) - params (cond-> - {:exports exports - :cmd cmd - :profile-id profile-id - :force-multiple true - :is-wasm (wasm-export-enabled? state)} - (some? name) - (assoc :name name)) - - progress-stream - (->> (ws/get-rcv-stream ws-conn) - (rx/filter ws/message-event?) - (rx/map :payload) - (rx/filter #(= :export-update (:type %))) - (rx/filter #(= @resource-id (:resource-id %))) - (rx/take-until failed-s) - (rx/share)) - - stopper - (rx/filter #(or (= "ended" (:status %)) - (= "error" (:status %)) - (= "cancelled" (:status %))) - progress-stream) - - file-ids - (export-file-ids exports)] - - (swap! st/ongoing-tasks conj :export) - - (rx/concat - (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) - (rx/of (warn-unsaved-changes file-ids)) - (rx/merge - ;; Updates the export state with each progress message, until - ;; shortly after the export ends or fails. - (->> progress-stream - (rx/map update-export-status) - (rx/take-until (rx/delay 500 stopper)) - (rx/finalize (fn [] - (swap! st/ongoing-tasks disj :export)))) - - ;; Clears the export state a while after the export ends or is - ;; cancelled. - (->> progress-stream - (rx/filter #(or (= "ended" (:status %)) - (= "cancelled" (:status %)))) - (rx/take 1) - (rx/delay default-timeout) - (rx/map #(clear-export-state @resource-id)) - (rx/take-until (rx/delay 6000 stopper))) - - ;; Launches the exportation process and stores the resource id - ;; locally. With wasm export active the job API is used instead: - ;; it answers with the exporter's own object count and gives a - ;; handle to cancel. It goes last so a failure reaches the - ;; progress listeners above: the push to `failed-s` releases the - ;; leave-page warning even though no progress message will ever - ;; arrive, and nothing is raised to the global error handler, - ;; which would replace the workspace with an error page. - (->> (if (wasm-export-enabled? state) - (->> (rp/cmd! :create-export-job params) - (rx/map (fn [{job-id :id :keys [total] :as job}] - (vreset! resource-id (:resource-id job)) - (initialize-export-status exports cmd - {:id (:resource-id job)} - {:job-id job-id - :total total - :status (:state job) - :backend (:backend job)})))) - (->> (rp/cmd! :export params) - (rx/map (fn [{:keys [id] :as resource}] - (vreset! resource-id id) - (initialize-export-status exports cmd resource nil))))) - (rx/catch (fn [cause] - (rx/push! failed-s cause) - (rx/of (export-widget-failed exports cmd cause)))))))))))) + (if (wasm-export-enabled? state) + (rx/of (request-local-export-wasm params)) + (rx/of (request-multiple-export-server params))))))) (defn request-export [{:keys [exports] :as params}] diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 696b8ed716..b23556474a 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -9,51 +9,113 @@ [app.common.media :refer [format->mtype]] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] - [app.util.webapi :as wapi])) + [app.util.webapi :as wapi] + [cuerdas.core :as str])) + +;; Regex shared with the exporter so ZIP entry names stay compatible. +(def ^:private sanitize-file-regex #"[\\/:*?\"<>|]") + +(defn export-extension + [type] + (case type + :png ".png" + :jpeg ".jpg" + :webp ".webp" + :svg ".svg" + :pdf ".pdf" + :zip ".zip" + "")) + +(defn sanitize-filename + "Exporter-compatible filename: strip reserved chars, then name+suffix+ext + with numeric de-dupe against `used`." + [{:keys [name suffix type]} used] + (let [base (-> (str (or name "") (or suffix "")) + (str/replace sanitize-file-regex "_"))] + (loop [index 0] + (let [candidate (str base + (when (pos? index) + (str "-" (inc index))) + (export-extension type))] + (if (contains? used candidate) + (recur (inc index)) + candidate))))) + +(defn export-bytes + "Encoded bytes for one export entry. Returns nil when WASM is not ready. + Used by the worker local export path (single / ZIP) and by main-thread + helpers (plugins, clipboard) that still need a sync URI. + + Raster formats use the GPU surface when it fits; otherwise the CPU + raster path (same as the headless exporter) so oversized shapes still + export locally without calling the server API." + [{:keys [type scale object-id] :as _export}] + (let [scale (or scale 1)] + (case type + :pdf (wasm.api/render-shape-pdf object-id) + :svg (wasm.api/render-shape-svg object-id scale) + (if (= :client (wasm.api/export-target object-id scale type)) + (wasm.api/render-shape-pixels object-id scale type) + (wasm.api/render-shape-raster object-id scale type))))) (defn export-image-uri - [{:keys [type scale object-id]}] + [{:keys [type] :as params}] ;; The export type doubles as the encoder format, so the bytes always match ;; the mtype we wrap them in. `:svg` never reaches here — it needs vector ;; markup and `exports.assets/wasm-export-types` keeps it off this path. - (let [bytes (wasm.api/render-shape-pixels object-id scale type) + (let [bytes (export-bytes params) mtype (format->mtype type) - blob (wapi/create-blob bytes mtype)] + blob (wapi/create-blob bytes mtype)] (wapi/create-uri blob))) (defn export-image [{:keys [type name] :as params}] - (let [url (export-image-uri params) + (let [url (export-image-uri params) mtype (format->mtype type)] (dom/trigger-download-uri name mtype url) (wapi/revoke-uri url) nil)) (defn export-pdf-uri - [{:keys [object-id]}] - (let [bytes (wasm.api/render-shape-pdf object-id) - blob (wapi/create-blob bytes "application/pdf")] + [params] + (let [bytes (export-bytes (assoc params :type :pdf)) + blob (wapi/create-blob bytes "application/pdf")] (wapi/create-uri blob))) (defn export-pdf [{:keys [name] :as params}] - (let [url (export-pdf-uri params) + (let [url (export-pdf-uri params) filename (str name ".pdf")] (dom/trigger-download-uri filename "application/pdf" url) (js/queueMicrotask #(wapi/revoke-uri url)) nil)) (defn export-svg-uri - [{:keys [scale object-id]}] - (let [bytes (wasm.api/render-shape-svg object-id (or scale 1)) - blob (wapi/create-blob bytes "image/svg+xml")] + [params] + (let [bytes (export-bytes (assoc params :type :svg)) + blob (wapi/create-blob bytes "image/svg+xml")] (wapi/create-uri blob))) (defn export-svg [{:keys [name] :as params}] - (let [url (export-svg-uri params) + (let [url (export-svg-uri params) filename (str name ".svg")] (dom/trigger-download-uri filename "image/svg+xml" url) (js/queueMicrotask #(wapi/revoke-uri url)) nil)) +(defn export-frames-pdf-uri + "One multi-page PDF for the given object ids (page order = id order). + Synchronous main-thread helper; multi-export prefers the worker session." + [{:keys [object-ids]}] + (let [bytes (wasm.api/render-shapes-pdf object-ids) + blob (wapi/create-blob bytes "application/pdf")] + (wapi/create-uri blob))) + +(defn export-frames-pdf + [{:keys [name] :as params}] + (let [url (export-frames-pdf-uri params) + filename (str (or name "export") ".pdf")] + (dom/trigger-download-uri filename "application/pdf" url) + (js/queueMicrotask #(wapi/revoke-uri url)) + nil)) diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index f1be8e32a2..83140cb47c 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -231,12 +231,13 @@ queued? (and (some? job-id) (= "queued" status)) cancelling? (and (some? job-id) (= "cancelling" status)) cancelled? (and (some? job-id) (= "cancelled" status)) - ;; Only the wasm backend can actually stop: a browser render holds its - ;; pool slot until playwright gives up. - cancellable? (and (some? job-id) - (= "wasm" (:backend state)) - (:in-progress state) - (not cancelling?)) + ;; Server wasm jobs and local client-side exports can stop; a non-wasm + ;; browser render holds its pool slot until playwright gives up. + cancellable? (and (:in-progress state) + (not cancelling?) + (or (= "local" (:backend state)) + (and (some? job-id) + (= "wasm" (:backend state))))) total (or (:total state) (count items)) complete? (= progress total) circ (* 2 Math/PI 12) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b31729caaa..f5af3275dd 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2899,29 +2899,47 @@ (when-let [snapshot wasm/canvas-snapshot] (reset! transition-image* snapshot))))) +(defn- read-raster-result + "Decode `[len][width][height][bytes]` from a `_render_shape_*` raster call." + [offset] + (let [heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 12) length)] + (mem/free) + result)) + (defn render-shape-pixels - "Renders a shape subtree to encoded image bytes. `format` is :png, :jpeg or - :webp; jpeg is flattened onto white on the Rust side, since it has no alpha - channel." + "Renders a shape subtree to encoded image bytes on the GPU export surface. + `format` is :png, :jpeg or :webp; jpeg is flattened onto white on the Rust + side, since it has no alpha channel." [shape-id scale format] (when (initialized?) (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_render_shape_pixels" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format))] + (read-raster-result offset)))) - offset - (h/call wasm/internal-module "_render_shape_pixels" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - scale - (sr/translate-raster-format format)) - - heap (mem/get-heap-u8) - heapu32 (mem/get-heap-u32) - length (aget heapu32 (mem/->offset-32 offset)) - result (dr/read-image-bytes heap (+ offset 12) length)] - (mem/free) - result))) +(defn render-shape-raster + "Same output as `render-shape-pixels`, but on a CPU Skia surface (no + GPU/WebGL). Used when the export exceeds the GPU max texture size — the + same path the headless exporter uses for wasm rasters." + [shape-id scale format] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_render_shape_raster" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format))] + (read-raster-result offset)))) (defn get-shape-extrect [shape-id] @@ -2976,6 +2994,66 @@ (mem/free) result))) +(defn render-shapes-pdf + "Renders each shape id as a page of one PDF. IDs are written to the + transfer buffer (same layout as `set-focus-mode`) before the call." + [shape-ids] + (when (and (initialized?) (seq shape-ids)) + (let [ids (vec shape-ids) + size (mem/get-alloc-size ids UUID-U8-SIZE) + heap (mem/get-heap-u32) + offset (mem/alloc->offset-32 size) + + _ (reduce (fn [offset id] + (mem.h32/write-uuid offset heap id)) + offset + ids) + + result-offset + (h/call wasm/internal-module "_render_shapes_pdf") + + heapu8 (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 result-offset)) + result (dr/read-image-bytes heapu8 (+ result-offset 4) length)] + (mem/free) + result))) + +(defn pdf-export-begin! + "Opens a progressive multi-page PDF session." + [] + (when (initialized?) + (h/call wasm/internal-module "_pdf_export_begin"))) + +(defn pdf-export-add-page! + "Appends `shape-id` as the next page of the current PDF session." + [shape-id] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id)] + (h/call wasm/internal-module "_pdf_export_add_page" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))))) + +(defn pdf-export-finish + "Closes the PDF session and returns the encoded PDF bytes." + [] + (when (initialized?) + (let [offset (h/call wasm/internal-module "_pdf_export_finish") + heapu8 (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heapu8 (+ offset 4) length)] + (mem/free) + result))) + +(defn pdf-export-abort! + "Drops an in-flight PDF session without returning bytes." + [] + (when (initialized?) + (h/call wasm/internal-module "_pdf_export_abort"))) + (defn render-shape-svg [shape-id scale] (when (initialized?) diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 860abcff27..5df69ac365 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -11,6 +11,7 @@ [app.common.schema :as sm] [app.common.types.objects-map] [app.util.object :as obj] + [app.worker.exports] [app.worker.impl :as impl] [app.worker.index] [app.worker.messages :as wm] diff --git a/frontend/src/app/worker/exports.cljs b/frontend/src/app/worker/exports.cljs new file mode 100644 index 0000000000..fa83b4ef29 --- /dev/null +++ b/frontend/src/app/worker/exports.cljs @@ -0,0 +1,260 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.worker.exports + "Local WASM export (single file, ZIP, multi-page PDF) on the worker module. + + Runs through `app.worker.wasm/submit` so it never interleaves with + thumbnail WASM jobs on the same module." + (:require + [app.common.logging :as log] + [app.common.media :as media] + [app.common.render-wasm.wasm :as wasm] + [app.common.types.color :as cc] + [app.common.uuid :as uuid] + [app.main.data.exports.wasm :as wasm.exports] + [app.render-wasm.api :as wasm.api] + [app.util.webapi :as wapi] + [app.util.zip :as uz] + [app.worker.impl :as impl] + [app.worker.wasm :as wwasm] + [beicon.v2.core :as rx] + [promesa.core :as p])) + +(log/set-level! :info) + +(def ^:private ^:const canvas-size 100) + +(defn- export-cancelled + [] + (ex-info "export cancelled" {:type :export-cancelled})) + +(defn- export-mtype + [type] + (case type + :pdf "application/pdf" + (media/format->mtype type))) + +(defn- build-single + "One shape → one downloadable blob (PNG/JPEG/WebP/SVG/PDF)." + [exports job-id on-progress] + (let [export (first exports)] + (when-not export + (throw (ex-info "no export entry" + {:type :validation + :code :empty-export}))) + (when (wwasm/cancelled? job-id) + (throw (export-cancelled))) + (let [bytes (wasm.exports/export-bytes export)] + (when-not bytes + (throw (ex-info "wasm export returned no bytes" + {:type :internal + :code :wasm-export-empty + :object-id (:object-id export)}))) + (on-progress 1 1) + (p/resolved (wapi/create-blob bytes (export-mtype (:type export))))))) + +(defn- build-zip + [exports job-id on-progress] + (let [writer (uz/writer (uz/blob-writer :mtype "application/zip")) + used (volatile! #{}) + entries + (mapv (fn [export] + (let [filename (wasm.exports/sanitize-filename export @used)] + (vswap! used conj filename) + [filename export])) + exports) + total (count entries)] + (->> (p/reduce + (fn [writer [index [filename export]]] + (when (wwasm/cancelled? job-id) + (throw (export-cancelled))) + (let [bytes (wasm.exports/export-bytes export)] + (when-not bytes + (throw (ex-info "wasm export returned no bytes" + {:type :internal + :code :wasm-export-empty + :object-id (:object-id export)}))) + (->> (uz/add writer filename bytes) + (p/fmap (fn [_] + (on-progress (inc index) total) + writer))))) + writer + (map-indexed vector entries)) + (p/mcat (fn [writer] + (uz/close writer))) + (p/merr (fn [cause] + (try (uz/close writer) (catch :default _)) + (p/rejected cause)))))) + +(defn- build-frames-pdf + [exports job-id on-progress] + (let [ids (mapv :object-id exports) + total (count ids)] + (wasm.api/pdf-export-begin!) + (->> (p/reduce + (fn [_ [index id]] + (when (wwasm/cancelled? job-id) + (throw (export-cancelled))) + (wasm.api/pdf-export-add-page! id) + (on-progress (inc index) total) + ;; Yield so a cancel that arrived during add-page can stop + ;; before the next page (nil would run every page in one turn). + (p/resolved nil)) + nil + (map-indexed vector ids)) + (p/mcat + (fn [_] + (let [bytes (wasm.api/pdf-export-finish)] + (when-not bytes + (throw (ex-info "wasm pdf export returned no bytes" + {:type :internal + :code :wasm-export-empty}))) + (p/resolved (wapi/create-blob bytes "application/pdf"))))) + (p/merr + (fn [cause] + (wasm.api/pdf-export-abort!) + (p/rejected cause)))))) + +(defn- export-entry + "Keeps only the fields the worker needs to render and name files." + [{:keys [object-id type scale name suffix]}] + {:object-id object-id + :type type + :scale (or scale 1) + :name name + :suffix suffix}) + +(defn- run-local-export + [{:keys [job-id kind exports objects background] export-name :name}] + (let [exports (mapv export-entry exports)] + (->> (rx/from @wwasm/module) + (rx/mapcat + (fn [_] + (rx/create + (fn [subs] + (let [done? (volatile! false) + ;; Never `rx/error!` here: the main `ask-many!` stream would + ;; hang because worker error replies used to drop `:error` + ;; and never send `completed`. Emit a terminal message instead. + finish! + (fn [result cause] + (when-not @done? + (vreset! done? true) + (try (wasm.api/clear-canvas) (catch :default _)) + (wwasm/clear-cancel job-id) + (cond + (or (wwasm/cancelled? job-id) + (= :export-cancelled (:type (ex-data cause)))) + (do + (rx/push! subs {:type :cancelled}) + (rx/end! subs)) + + cause + (do + (rx/push! subs {:type :error + :hint (ex-message cause) + :data (ex-data cause)}) + (rx/end! subs)) + + :else + (let [entry (first exports) + filename (case kind + :pdf-frames + (str (or export-name "export") ".pdf") + + :single + (wasm.exports/sanitize-filename entry #{}) + + (str (or export-name "export") ".zip")) + mtype (case kind + :pdf-frames "application/pdf" + :single (export-mtype (:type entry)) + "application/zip")] + (rx/push! subs {:type :done + :filename filename + :mtype mtype + :blob result}) + (rx/end! subs))))) + + on-progress + (fn [done total] + (rx/push! subs {:type :progress + :done done + :total total})) + + start-export! + (fn [] + ;; `:on-render` runs after process-pending (images + + ;; fonts). `:on-shapes-ready` fires earlier and would + ;; export empty image fills / missing typefaces. + (when (wwasm/cancelled? job-id) + (finish! nil (export-cancelled))) + (when-not @done? + (try + (let [work (case kind + :pdf-frames + (build-frames-pdf exports job-id on-progress) + + :single + (build-single exports job-id on-progress) + + (build-zip exports job-id on-progress))] + (->> work + (p/fnly (fn [blob cause] + (finish! blob cause))))) + (catch :default cause + (finish! nil cause))))) + + canvas (js/OffscreenCanvas. canvas-size canvas-size) + init? (wasm.api/init-canvas-context canvas)] + (cond + (not init?) + (finish! nil (ex-info "failed to init worker wasm canvas" + {:type :internal + :code :wasm-canvas-init-failed})) + + (not (wasm/live?)) + (finish! nil (ex-info "worker wasm context is not live" + {:type :internal + :code :wasm-not-live})) + + (empty? objects) + (finish! nil (ex-info "no shapes to export" + {:type :validation + :code :empty-export-objects})) + + (wwasm/cancelled? job-id) + (finish! nil (export-cancelled)) + + :else + (try + (let [bgcolor (or background cc/canvas) + vbox {:x 0 :y 0 :width canvas-size :height canvas-size}] + (reset! wasm/disable-request-render? true) + (wasm.api/initialize-viewport + objects 1 vbox + :background bgcolor + :force-sync true + :on-render start-export!)) + (catch :default cause + (finish! nil cause)))) + (fn [] + (wwasm/cancel-job job-id) + (try (wasm.api/pdf-export-abort!) (catch :default _)) + (try (wasm.api/clear-canvas) (catch :default _))))))))))) + +(defmethod impl/handler :exports/run-local + [message _] + (let [job-id (or (:job-id message) (uuid/next)) + msg (assoc message :job-id job-id)] + (wwasm/submit #(run-local-export msg)))) + +(defmethod impl/handler :exports/cancel + [{:keys [job-id]} _] + (when job-id + (wwasm/cancel-job job-id)) + nil) diff --git a/frontend/src/app/worker/messages.cljs b/frontend/src/app/worker/messages.cljs index 2bb1c6a859..209deb37ef 100644 --- a/frontend/src/app/worker/messages.cljs +++ b/frontend/src/app/worker/messages.cljs @@ -11,26 +11,62 @@ [app.common.transit :as t] [app.util.object :as obj])) -(defn encode [{:keys [sender-id reply-to payload buffer?] :as message}] - #js {:cmd (d/name (:cmd payload)) - :senderId (when sender-id (str sender-id)) - :replyTo (when reply-to (str reply-to)) - :payload (if (= :initialize-indices (:cmd payload)) - (:file-raw payload) - (when (some? payload) (t/encode-str payload))) - :buffer (when (some? buffer?) buffer?)}) +(defn encode + "Serializes a worker message for `postMessage`. + + Transit cannot carry `js/Blob`. If the payload map has `:blob`, or the + message already has a top-level `:blob`, that value is sent as a + structured-clone field beside the Transit string — not inside it." + [{:keys [sender-id reply-to payload buffer? blob completed error]}] + (let [;; Peel `:blob` out of map payloads before Transit (selection/snap + ;; replies are vectors and other non-maps; leave them untouched). + inline-blob (when (map? payload) (:blob payload)) + blob (or blob inline-blob) + payload (cond-> payload + (some? inline-blob) + (dissoc :blob)) + obj #js {:cmd (d/name (:cmd payload)) + :senderId (when sender-id (str sender-id)) + :replyTo (when reply-to (str reply-to)) + :payload (if (= :initialize-indices (:cmd payload)) + (:file-raw payload) + (when (some? payload) (t/encode-str payload))) + :buffer (when (some? buffer?) buffer?) + :completed (when completed true)}] + (when (some? blob) + (obj/set! obj "blob" blob)) + (when (some? error) + (obj/set! obj "error" (clj->js error))) + obj)) + +(defn- js->error-map + [^js err] + (when err + (let [m (js->clj err :keywordize-keys true)] + (cond-> m + (string? (:data m)) + (assoc :data nil))))) (defn decode [^js data] (let [cmd (obj/get data "cmd") sender-id (obj/get data "senderId") reply-to (obj/get data "replyTo") payload (obj/get data "payload") - buffer (obj/get data "buffer")] - (d/without-nils - {:sender-id (when sender-id (uuid sender-id)) - :reply-to (when reply-to (uuid reply-to)) - :payload (if (= cmd "initialize-indices") - {:cmd :initialize-indices - :file-raw payload} - (when (some? payload) (t/decode-str payload))) - :buffer? buffer}))) + buffer (obj/get data "buffer") + blob (obj/get data "blob") + completed (obj/get data "completed") + error (obj/get data "error") + decoded + (d/without-nils + {:sender-id (when sender-id (uuid sender-id)) + :reply-to (when reply-to (uuid reply-to)) + :payload (if (= cmd "initialize-indices") + {:cmd :initialize-indices + :file-raw payload} + (when (some? payload) (t/decode-str payload))) + :buffer? buffer + :completed completed + :error (js->error-map error)})] + (cond-> decoded + (some? blob) + (update :payload assoc :blob blob)))) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index e42a9687b4..074b23c400 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -14,13 +14,13 @@ [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as cc] [app.common.uri :as u] - [app.common.uuid :as uuid] [app.config :as cf] [app.main.fonts :as fonts] [app.main.render :as render] [app.render-wasm.api :as wasm.api] [app.util.http :as http] [app.worker.impl :as impl] + [app.worker.wasm :as wwasm] [beicon.v2.core :as rx] [okulary.core :as l] [promesa.core :as p] @@ -138,23 +138,12 @@ :height height :background background})))))) -(defonce ^:private wasm-module - (delay - (let [module (unchecked-get js/globalThis "WasmModule") - init-fn (unchecked-get module "default") - href (cf/resolve-href "js/render-wasm.wasm")] - (->> (init-fn #js {:locateFile (constantly href)}) - (p/fnly (fn [module cause] - (if cause - (js/console.error cause) - (set! wasm/internal-module module)))))))) - (defn- render-thumbnail-with-wasm - [{:keys [id file-id revn width] :as message}] - (->> (rx/from @wasm-module) + [{:keys [file-id revn width] :as _message}] + (->> (rx/from @wwasm/module) (rx/mapcat #(request-data-for-thumbnail file-id revn false)) (rx/mapcat - (fn [{:keys [page] :as file}] + (fn [{:keys [page] :as _file}] (rx/create (fn [subs] (let [bgcolor (or (:background page) cc/canvas) @@ -187,34 +176,13 @@ (if cause (rx/error! subs cause) (rx/push! subs - {:id id - :data data + {:data data :file-id file-id :revn revn})) (rx/end! subs))))))) (rx/end! subs)) nil))))))) -(defonce ^:private - thumbnails-queue - (rx/subject)) - -(defonce ^:private - thumbnails-stream - (->> thumbnails-queue - (rx/mapcat render-thumbnail-with-wasm) - (rx/share))) - (defmethod impl/handler :thumbnails/generate-for-file-wasm [message _] - (rx/create - (fn [subs] - (let [id (uuid/next) - sid (->> thumbnails-stream - (rx/filter #(= id (:id %))) - (rx/subs! - (fn [result] - (rx/push! subs result) - (rx/end! subs))))] - (rx/push! thumbnails-queue (assoc message :id id)) - #(rx/dispose! sid))))) + (wwasm/submit #(render-thumbnail-with-wasm message))) diff --git a/frontend/src/app/worker/wasm.cljs b/frontend/src/app/worker/wasm.cljs new file mode 100644 index 0000000000..eca6c47071 --- /dev/null +++ b/frontend/src/app/worker/wasm.cljs @@ -0,0 +1,87 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.worker.wasm + "Shared render-wasm loader and serial work queue for the web worker. + + Thumbnails and local multi-export share one WASM module instance. Jobs that + touch that module (initialize-viewport, render, clear-canvas) must run through + `submit` so they never interleave." + (:require + [app.common.render-wasm.wasm :as wasm] + [app.config :as cf] + [beicon.v2.core :as rx] + [promesa.core :as p])) + +;; Delay that resolves to the worker's WasmModule instance. +(defonce module + (delay + (let [module (unchecked-get js/globalThis "WasmModule") + init-fn (unchecked-get module "default") + href (cf/resolve-href "js/render-wasm.wasm")] + (->> (init-fn #js {:locateFile (constantly href)}) + (p/fnly (fn [module cause] + (if cause + (js/console.error "worker wasm module init failed" cause) + (set! wasm/internal-module module)))))))) + +(defonce ^:private work-queue + (rx/subject)) + +(defonce ^:private cancelled-jobs* + (atom #{})) + +(defonce ^:private executor-sub* + (atom nil)) + +(defn- ensure-executor! + "Starts the serial drain once. Must be reachable from `submit` so release + builds cannot DCE the subscription (a lone unused `defonce` is removed)." + [] + (when (nil? @executor-sub*) + (reset! executor-sub* + (->> work-queue + (rx/mapcat + (fn [{:keys [bus work]}] + (->> (work) + (rx/tap (fn [value] + (rx/push! bus value))) + (rx/catch (fn [cause] + (rx/error! bus cause) + (rx/empty))) + (rx/finalize + (fn [] + (rx/end! bus)))))) + (rx/subs! + (fn [_]) + (fn [cause] + (js/console.error "worker wasm queue error" cause))))))) + +(defn cancel-job + "Marks `job-id` so an in-flight export stops between items." + [job-id] + (swap! cancelled-jobs* conj job-id) + nil) + +(defn cancelled? + [job-id] + (contains? @cancelled-jobs* job-id)) + +(defn clear-cancel + [job-id] + (swap! cancelled-jobs* disj job-id)) + +(defn submit + "Runs `work` (zero-arg → observable) on the serial WASM queue. + Returns a subject that mirrors `work` emissions and errors. + + Pushes onto the queue immediately (does not wait for a subscriber) so the + job starts even if the caller is slow to subscribe." + [work] + (ensure-executor!) + (let [bus (rx/subject)] + (rx/push! work-queue {:bus bus :work work}) + bus)) diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index a9c9393f25..4673b13411 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -8,12 +8,16 @@ (:require [app.common.uuid :as uuid] [app.main.data.exports.assets :as de] + [app.main.data.exports.wasm :as wasm.exports] [app.main.data.persistence :as dwp] [app.main.errors :as errors] [app.main.repo :as repo] [app.main.store :as st] + [app.main.worker :as mw] + [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.i18n :as i18n] + [app.util.webapi :as wapi] [app.util.websocket :as ws] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] @@ -21,7 +25,6 @@ [frontend-tests.helpers.events :as the] [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) - (def ^:private export {:id (uuid/next) :object-id (uuid/next) :type :png @@ -37,6 +40,17 @@ {:profile-id (:id export) :ws-conn nil}) +(defn- wasm-state + [] + (assoc (test-state) :features #{"render-wasm/v1"})) + +(t/deftest sanitize-filename-strips-reserved-chars-and-dedupes + (t/is (= "a_b.png" + (wasm.exports/sanitize-filename {:name "a/b" :suffix "" :type :png} #{}))) + (t/is (= "board-2.pdf" + (wasm.exports/sanitize-filename {:name "board" :suffix "" :type :pdf} + #{"board.pdf"})))) + (t/deftest normalize-export-preserves-existing-name (t/is (= (export-with-name "Layer 1") (de/normalize-export (export-with-name "Layer 1"))))) @@ -203,3 +217,240 @@ (await (async/wait-for #(true? @downloaded) "the export runs")) (await (async/settle)) (t/is (nil? (:notification @store))))))) + +;; Scenario: WASM is on and the user exports one shape. Proves: the worker +;; builds a single blob (no ZIP); the exporter is not called. +;; Stub `export-target`: without a module it is nil; routing still stays +;; local (never the server API under wasm). +(t/deftest ^:async wasm-simple-export-stays-local + (let [cmd-calls (atom []) + worker-kind (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + one (assoc export :enabled true :name "layer" + :page-id page-id :file-id file-id)] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + wasm.api/export-target (mock/stub (constantly :client)) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-kind (:kind msg)) + (rx/of {:type :done + :filename "layer.png" + :mtype "image/png" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:png")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-simple-export {:export one})) + (await (async/wait-for #(some? @downloaded) "the file downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :single @worker-kind)) + (t/is (= ["layer.png" "image/png" "blob:png"] @downloaded)))))) + +;; Scenario: WASM says the raster is too big for the GPU (legacy `:exporter` +;; code). Proves: still local worker download, no server RPC — CPU raster +;; runs inside the worker via `export-bytes`. +(t/deftest ^:async wasm-oversized-for-gpu-stays-local + (let [cmd-calls (atom []) + worker-kind (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + one (assoc export :enabled true :name "huge")] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + wasm.api/export-target (mock/stub (constantly :exporter)) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-kind (:kind msg)) + (rx/of {:type :done + :filename "huge.png" + :mtype "image/png" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:png")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-simple-export {:export one})) + (await (async/wait-for #(some? @downloaded) "the file downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :single @worker-kind)) + (t/is (= ["huge.png" "image/png" "blob:png"] @downloaded)))))) + +;; Scenario: WASM reports true `:too-large` (beyond raster limits). Proves: +;; toast and no RPC — the exporter would fail the same way. +(t/deftest ^:async wasm-true-too-large-shows-toast + (let [cmd-calls (atom []) + store (export-store (wasm-state) (atom [])) + one (assoc export :enabled true :name "impossible")] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + wasm.api/export-target (mock/stub (constantly :too-large)) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export one})) + (await (async/wait-for #(some? (:notification @store)) "the toast shows")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= "translated:errors.export-too-large" + (get-in @store [:notification :content]))) + (t/is (= :error (get-in @store [:notification :level]))))))) + +;; Scenario: WASM is on and the user exports several shapes. Proves: nothing +;; hits the exporter; the worker builds a ZIP and the client downloads it. +(t/deftest ^:async wasm-multiple-export-stays-local + (let [cmd-calls (atom []) + worker-cmd (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + exports [(assoc export :enabled true :name "a" :page-id page-id :file-id file-id) + (assoc export :id (uuid/next) + :object-id (uuid/next) + :page-id page-id + :file-id file-id + :enabled true + :name "b")]] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-cmd (:cmd msg)) + (rx/of {:type :done + :filename "page.zip" + :mtype "application/zip" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:zip")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-multiple-export {:exports exports :name "page"})) + (await (async/wait-for #(some? @downloaded) "the zip downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :exports/run-local @worker-cmd)) + (t/is (= ["page.zip" "application/zip" "blob:zip"] @downloaded)))))) + +;; Scenario: WASM is on and the user exports boards as PDF. Proves: worker +;; multi-page PDF, no exporter call. +(t/deftest ^:async wasm-export-frames-stays-local + (let [cmd-calls (atom []) + worker-kind (atom nil) + downloaded (atom nil) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + exports [(assoc export :enabled true :name "board-1" + :page-id page-id :file-id file-id) + (assoc export :id (uuid/next) + :object-id (uuid/next) + :page-id page-id + :file-id file-id + :enabled true + :name "board-2")]] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [cmd params] + (swap! cmd-calls conj [cmd params]) + (rx/throw (ex-info "exporter must not run" {})))) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [msg] + (reset! worker-kind (:kind msg)) + (rx/of {:type :progress :done 1 :total 2} + {:type :progress :done 2 :total 2} + {:type :done + :filename "page.pdf" + :mtype "application/pdf" + :blob (js/Blob.)}))) + wapi/create-uri (mock/stub (constantly "blob:pdf")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-multiple-export {:exports exports + :cmd :export-frames + :name "page"})) + (await (async/wait-for #(some? @downloaded) "the pdf downloads")) + (t/is (empty? @cmd-calls) "no exporter RPC") + (t/is (= :pdf-frames @worker-kind)) + (t/is (= ["page.pdf" "application/pdf" "blob:pdf"] @downloaded)))))) + +;; Scenario: the user cancels a local WASM multi-export. Proves: the widget +;; stays cancelled and late worker :progress/:done do not download or flip +;; status back to running. +(t/deftest ^:async wasm-local-cancel-ignores-late-worker-messages + (let [downloaded (atom nil) + cancel-cmds (atom []) + worker-bus (rx/subject) + store (export-store (wasm-state) (atom [])) + page-id (uuid/next) + file-id (uuid/next) + exports [(assoc export :enabled true :name "a" + :page-id page-id :file-id file-id) + (assoc export :id (uuid/next) + :object-id (uuid/next) + :page-id page-id + :file-id file-id + :enabled true + :name "b")]] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "exporter must not run" {})))) + de/collect-export-objects (mock/stub (fn [_ _] {})) + mw/ask-many! (mock/stub (fn [_] worker-bus)) + mw/emit! (mock/stub (fn [msg] + (swap! cancel-cmds conj msg))) + wapi/create-uri (mock/stub (constantly "blob:zip")) + dom/trigger-download-uri (mock/stub (fn [filename mtype uri] + (reset! downloaded [filename mtype uri]))) + st/ongoing-tasks (atom #{})} + (ptk/emit! store (de/request-multiple-export {:exports exports :name "page"})) + (await (async/wait-for #(= "running" (get-in @store [:export :status])) + "export is running")) + (rx/push! worker-bus {:type :progress :done 1 :total 2}) + (await (async/wait-for #(= 1 (get-in @store [:export :progress])) + "first progress lands")) + (ptk/emit! store (de/cancel-export)) + (await (async/wait-for #(= "cancelled" (get-in @store [:export :status])) + "export is cancelled")) + (rx/push! worker-bus {:type :progress :done 2 :total 2}) + (rx/push! worker-bus {:type :done + :filename "page.zip" + :mtype "application/zip" + :blob (js/Blob.)}) + (await (async/settle)) + (t/is (= "cancelled" (get-in @store [:export :status]))) + (t/is (nil? @downloaded) "late :done does not download") + (t/is (= :exports/cancel (:cmd (first @cancel-cmds)))) + (rx/end! worker-bus))))) + +(t/deftest collect-export-objects-keeps-only-roots-and-descendants + (let [page-id (uuid/next) + file-id (uuid/next) + root (uuid/next) + child (uuid/next) + other (uuid/next) + objects {root {:id root :parent-id uuid/zero :shapes [child]} + child {:id child :parent-id root :shapes []} + other {:id other :parent-id uuid/zero :shapes []}} + state {:files {file-id {:id file-id + :data {:pages-index + {page-id {:id page-id + :objects objects}}}}}} + exports [{:object-id root :page-id page-id :file-id file-id}] + selected (de/collect-export-objects state exports)] + (t/is (= #{root child} (set (keys selected)))) + (t/is (not (contains? selected other))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5dd9553f7d..f2585704e8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -153,6 +153,7 @@ [frontend-tests.util-zip-test] [frontend-tests.util.dom.dnd-test] [frontend-tests.worker-snap-test] + [frontend-tests.worker.exports-test] [goog.object :as gobj])) (enable-console-print!) @@ -186,6 +187,7 @@ 'frontend-tests.data.exports-files-test 'frontend-tests.data.imports-test 'frontend-tests.data.jobs-test + 'frontend-tests.worker.exports-test 'frontend-tests.data.svg-upload-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test diff --git a/frontend/test/frontend_tests/worker/exports_test.cljs b/frontend/test/frontend_tests/worker/exports_test.cljs new file mode 100644 index 0000000000..5f1a9f6953 --- /dev/null +++ b/frontend/test/frontend_tests/worker/exports_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.worker.exports-test + (:require + [app.common.uuid :as uuid] + [app.worker.wasm :as wwasm] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async])) + +;; Record order inside `work`: `submit` starts before any subscriber, so +;; tapping the returned bus can miss values. Plain Promise (not promesa): +;; `await` + promesa crashed the full suite in Node. +(t/deftest ^:async submit-runs-jobs-serially + (let [order (atom [])] + (await + (js/Promise. + (fn [resolve reject] + (let [timer (js/setTimeout + #(reject (js/Error. "submit queue timed out")) + 2000)] + (wwasm/submit + (fn [] + (swap! order conj :a-start) + (rx/concat + (rx/of :a-start) + (->> (rx/of :a-end) + (rx/delay 20) + (rx/tap #(swap! order conj :a-end)))))) + (wwasm/submit + (fn [] + (swap! order conj + {:b-sees-a-start (boolean (some #{:a-start} @order)) + :b-sees-a-end (boolean (some #{:a-end} @order))}) + (swap! order conj :b-end) + (->> (rx/of :b-end) + (rx/finalize + (fn [] + (js/clearTimeout timer) + (resolve nil)))))))))) + (t/is (= [:a-start :a-end + {:b-sees-a-start true :b-sees-a-end true} + :b-end] + @order) + "job B starts only after job A has finished"))) + +(t/deftest cancel-job-is-visible-to-cancelled + (let [job-id (uuid/next)] + (t/is (false? (wwasm/cancelled? job-id))) + (wwasm/cancel-job job-id) + (t/is (true? (wwasm/cancelled? job-id))) + (wwasm/clear-cancel job-id) + (t/is (false? (wwasm/cancelled? job-id))))) + +;; After a macrotask yield (as between PDF pages), `cancelled?` still holds. +(t/deftest ^:async cancel-is-honored-after-async-yield + (let [job-id (uuid/next) + steps (atom [])] + (swap! steps conj :page-1) + (wwasm/cancel-job job-id) + (await (async/settle)) + (if (wwasm/cancelled? job-id) + (swap! steps conj :stopped) + (swap! steps conj :page-2)) + (t/is (= [:page-1 :stopped] @steps)) + (wwasm/clear-cancel job-id))) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 9e590fa86a..0d39d76045 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -859,6 +859,69 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32) -> Result<*mu }) } +/// Multi-page PDF: shape UUIDs are read from the transfer buffer (16 bytes +/// each, same layout as `set_focus_mode`). Returns `[len u32 LE][pdf bytes]`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shapes_pdf() -> Result<*mut u8> { + let bytes = mem::bytes(); + let ids: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + with_state!(state, { + let data = state.render_shapes_pdf(&ids)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + +/// Starts a progressive multi-page PDF session (see `pdf_export_add_page` / +/// `pdf_export_finish`). Replaces any previous session. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_begin() -> Result<()> { + with_state!(state, { state.pdf_export_begin() }) +} + +/// Appends one shape as a page of the current PDF export session. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_add_page(a: u32, b: u32, c: u32, d: u32) -> Result<()> { + let id = uuid_from_u32_quartet(a, b, c, d); + with_state!(state, { state.pdf_export_add_page(&id) }) +} + +/// Finishes the PDF export session and returns `[len u32 LE][pdf bytes]`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_finish() -> Result<*mut u8> { + with_state!(state, { + let data = state.pdf_export_finish()?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + +/// Aborts the current PDF export session, if any. +#[no_mangle] +#[wasm_error] +pub extern "C" fn pdf_export_abort() -> Result<()> { + with_state!(state, { + state.pdf_export_abort(); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index d70f353ef4..746b26242d 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -1,6 +1,8 @@ +use std::mem::ManuallyDrop; + use skia_safe as skia; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -10,50 +12,291 @@ use super::RenderResources; mod glyph_text; mod to_unicode; -/// Renders a shape tree to a PDF document and returns the raw PDF bytes. -/// -/// This is a dedicated vector-PDF render path that draws directly to a Skia -/// PDF canvas, bypassing the GPU surface system entirely. The result is a -/// true vector PDF — paths, text and fills are represented as PDF drawing -/// operations rather than rasterised bitmaps. Effects that are inherently -/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's -/// PDF backend +/// In-flight multi-page PDF. The byte buffer and metadata are heap-pinned so +/// Skia's `Document` can hold `'static` write/metadata references across FFI +/// calls (skia-safe ties the document lifetime to both). Stored on +/// [`crate::state::State`], not a process-wide static. +pub(crate) struct PdfExportSession { + bytes: *mut Vec, + metadata: *mut skia::pdf::Metadata<'static>, + document: ManuallyDrop>, + roots: Vec, +} + +fn pdf_metadata() -> skia::pdf::Metadata<'static> { + skia::pdf::Metadata { + creator: "Penpot".to_string(), + producer: "Penpot (Skia PDF)".to_string(), + raster_dpi: Some(300.0), + ..Default::default() + } +} + +/// Starts a multi-page PDF session. Aborts any previous session in `slot` first. +pub fn export_begin(slot: &mut Option) -> Result<()> { + export_abort(slot); + let bytes = Box::into_raw(Box::new(Vec::new())); + let metadata = Box::into_raw(Box::new(pdf_metadata())); + // SAFETY: `bytes` and `metadata` stay pinned until finish/abort. Document + // writes through `&mut Vec`; reallocating the Vec's heap buffer is + // fine because the reference targets the Vec, not its buffer. + let document = unsafe { + let bytes_ref: &'static mut Vec = &mut *bytes; + let metadata_ref: &'static skia::pdf::Metadata<'static> = &*metadata; + skia::pdf::new_document(bytes_ref, Some(metadata_ref)) + }; + *slot = Some(PdfExportSession { + bytes, + metadata, + document: ManuallyDrop::new(document), + roots: Vec::new(), + }); + Ok(()) +} + +/// Appends one shape as a new page. Call between `export_begin` and +/// `export_finish`. +pub fn export_add_page( + slot: &mut Option, + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, +) -> Result<()> { + let session = slot + .as_mut() + .ok_or_else(|| Error::CriticalError("PDF export session not started".to_string()))?; + + let shape = tree + .get(id) + .ok_or_else(|| Error::CriticalError(format!("Shape not found for PDF: {id}")))?; + // Boards crop overflowing children (classic board box) but keep their own + // strokes/shadows/blur. Other shapes use full extrect. + let bounds = shape.export_root_bounds(tree, 1.0); + + // SAFETY: document was created against session.bytes; we put it back. + let document = unsafe { ManuallyDrop::take(&mut session.document) }; + let mut on_page = document.begin_page((bounds.width(), bounds.height()), None); + { + let page_canvas = on_page.canvas(); + page_canvas.translate((-bounds.left(), -bounds.top())); + if let Err(err) = vector::render_tree_pdf(shared, page_canvas, id, tree, bounds) { + // Restore the document so abort/finish can still run; do not + // record this root — the page content failed. + let document = on_page.end_page(); + session.document = ManuallyDrop::new(document); + return Err(err); + } + } + let document = on_page.end_page(); + session.document = ManuallyDrop::new(document); + session.roots.push(*id); + Ok(()) +} + +fn drop_session_buffers(bytes: *mut Vec, metadata: *mut skia::pdf::Metadata<'static>) { + unsafe { + drop(Box::from_raw(metadata)); + drop(Box::from_raw(bytes)); + } +} + +/// Closes the session and returns the PDF bytes (with ToUnicode fixup). +pub fn export_finish(slot: &mut Option, tree: ShapesPoolRef) -> Result> { + let session = slot + .take() + .ok_or_else(|| Error::CriticalError("PDF export session not started".to_string()))?; + + if session.roots.is_empty() { + let document = ManuallyDrop::into_inner(session.document); + document.close(); + drop_session_buffers(session.bytes, session.metadata); + return Err(Error::CriticalError( + "No shapes for multi-page PDF".to_string(), + )); + } + + let roots = session.roots; + let document = ManuallyDrop::into_inner(session.document); + document.close(); + let pdf_bytes = unsafe { *Box::from_raw(session.bytes) }; + unsafe { + drop(Box::from_raw(session.metadata)); + } + + // Skia maps ligatures and other substituted glyphs to U+0000; give them + // back their text so it can be copied from the PDF. + let glyph_texts = glyph_text::collect_many(tree, &roots); + Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts)) +} + +/// Drops an in-flight session without returning bytes. +pub fn export_abort(slot: &mut Option) { + let Some(session) = slot.take() else { + return; + }; + let document = ManuallyDrop::into_inner(session.document); + document.close(); + drop_session_buffers(session.bytes, session.metadata); +} + +/// Renders a shape tree to a single-page PDF and returns the raw PDF bytes. pub fn render_to_pdf( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, ) -> Result> { - let shape = tree - .get(id) - .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; - // Boards crop overflowing children (classic board box) but keep their own - // strokes/shadows/blur. Other shapes use full extrect. - let bounds = shape.export_root_bounds(tree, 1.0); + render_to_pdf_multi(shared, &[*id], tree) +} - let mut pdf_bytes: Vec = Vec::new(); +/// Renders each shape root as a page of one PDF document, in `ids` order. +/// Uses a local session so it does not disturb a progressive export in +/// [`crate::state::State`]. +pub fn render_to_pdf_multi( + shared: &mut RenderResources, + ids: &[Uuid], + tree: ShapesPoolRef, +) -> Result> { + if ids.is_empty() { + return Err(Error::CriticalError( + "No shapes for multi-page PDF".to_string(), + )); + } + let mut session = None; + export_begin(&mut session)?; + for id in ids { + if let Err(err) = export_add_page(&mut session, shared, id, tree) { + export_abort(&mut session); + return Err(err); + } + } + match export_finish(&mut session, tree) { + Ok(bytes) => Ok(bytes), + Err(err) => { + export_abort(&mut session); + Err(err) + } + } +} - let metadata = skia::pdf::Metadata { - creator: "Penpot".to_string(), - producer: "Penpot (Skia PDF)".to_string(), - raster_dpi: Some(300.0), - ..Default::default() - }; +#[cfg(test)] +mod tests { + use super::*; - let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata)); + use crate::shapes::{Fill, Rect, SolidColor, Type}; + use crate::state::ShapesPool; + use crate::utils::uuid_from_u32_quartet; - let mut on_page = document.begin_page((bounds.width(), bounds.height()), None); + use skia_safe as skia; - { - let page_canvas = on_page.canvas(); - page_canvas.translate((-bounds.left(), -bounds.top())); - vector::render_tree_pdf(shared, page_canvas, id, tree, bounds)?; + fn uid(n: u32) -> Uuid { + uuid_from_u32_quartet(0, 0, 0, n) } - let document = on_page.end_page(); - document.close(); + fn add_solid_rect( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, + ) { + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(color))]); + } - // Skia maps ligatures and other substituted glyphs to U+0000; give them - // back their text so it can be copied from the PDF. - let glyph_texts = glyph_text::collect(tree, id); - Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts)) + /// Counts page objects: `/Type /Page` entries that are not `/Pages`. + fn page_count(pdf: &[u8]) -> usize { + let text = String::from_utf8_lossy(pdf); + text.match_indices("/Type /Page") + .filter(|(i, _)| !text[*i..].starts_with("/Type /Pages")) + .count() + } + + #[test] + fn multi_page_pdf_has_one_page_per_shape() { + let mut pool = ShapesPool::new(); + let a = uid(1); + let b = uid(2); + add_solid_rect( + &mut pool, + a, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + add_solid_rect( + &mut pool, + b, + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(0, 0, 255), + ); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let pdf = render_to_pdf_multi(&mut resources, &[a, b], &pool).expect("pdf"); + assert!(pdf.starts_with(b"%PDF"), "missing PDF header"); + assert_eq!(page_count(&pdf), 2, "expected two pages"); + } + + #[test] + fn session_api_reports_progress_sized_pages() { + let mut pool = ShapesPool::new(); + let a = uid(1); + let b = uid(2); + add_solid_rect( + &mut pool, + a, + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + add_solid_rect( + &mut pool, + b, + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(0, 0, 255), + ); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let mut session = None; + export_begin(&mut session).expect("begin"); + export_add_page(&mut session, &mut resources, &a, &pool).expect("page a"); + export_add_page(&mut session, &mut resources, &b, &pool).expect("page b"); + let pdf = export_finish(&mut session, &pool).expect("finish"); + assert_eq!(page_count(&pdf), 2); + } + + #[test] + fn empty_id_list_is_an_error() { + let pool = ShapesPool::new(); + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let err = render_to_pdf_multi(&mut resources, &[], &pool).expect_err("empty"); + assert!( + matches!(err, Error::CriticalError(_)), + "empty ids must fail critically: {err:?}" + ); + } + + #[test] + fn single_page_wrapper_still_works() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + (0.0, 0.0, 50.0, 50.0), + skia::Color::from_rgb(0, 128, 0), + ); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let pdf = render_to_pdf(&mut resources, &id, &pool).expect("pdf"); + assert!(pdf.starts_with(b"%PDF")); + assert_eq!(page_count(&pdf), 1); + } } diff --git a/render-wasm/src/render/pdf/glyph_text.rs b/render-wasm/src/render/pdf/glyph_text.rs index d3623bf87f..71ba721343 100644 --- a/render-wasm/src/render/pdf/glyph_text.rs +++ b/render-wasm/src/render/pdf/glyph_text.rs @@ -197,6 +197,25 @@ pub fn collect(tree: ShapesPoolRef, root: &Uuid) -> GlyphTexts { glyph_texts } +/// Merges glyph texts from every root in `ids` (export-frames / multi PDF). +pub fn collect_many(tree: ShapesPoolRef, ids: &[Uuid]) -> GlyphTexts { + let mut result = GlyphTexts::default(); + for id in ids { + result.merge(collect(tree, id)); + } + result +} + +impl GlyphTexts { + fn merge(&mut self, other: GlyphTexts) { + for (font, glyphs) in other.fonts { + for (glyph, value) in glyphs { + self.insert(&font, glyph, value); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index a3e3d91296..d3b803b18c 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -28,6 +28,8 @@ pub(crate) struct State { pub saved_shapes: Option, /// True while the first bulk load of shapes is in progress. pub loading: bool, + /// Progressive multi-page PDF export (begin / add-page / finish). + pub pdf_export: Option, } impl State { @@ -38,6 +40,7 @@ impl State { shapes: ShapesPool::new(), saved_shapes: None, loading: false, + pdf_export: None, } } @@ -103,6 +106,26 @@ impl State { crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes) } + pub fn render_shapes_pdf(&mut self, ids: &[Uuid]) -> Result> { + crate::render::pdf::render_to_pdf_multi(get_resources(), ids, &self.shapes) + } + + pub fn pdf_export_begin(&mut self) -> Result<()> { + crate::render::pdf::export_begin(&mut self.pdf_export) + } + + pub fn pdf_export_add_page(&mut self, id: &Uuid) -> Result<()> { + crate::render::pdf::export_add_page(&mut self.pdf_export, get_resources(), id, &self.shapes) + } + + pub fn pdf_export_finish(&mut self) -> Result> { + crate::render::pdf::export_finish(&mut self.pdf_export, &self.shapes) + } + + pub fn pdf_export_abort(&mut self) { + crate::render::pdf::export_abort(&mut self.pdf_export) + } + pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result> { crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale) } From c3a23174a188df7d3d3030b0a87719b67542b57d Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:50:52 +0200 Subject: [PATCH 138/325] :arrow_up: Update DHI base images (#12597) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13@de80b46 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@9b02576 (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.7-debian13-dev@940ee35 (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.1@ (docker/devenv/Dockerfile) - dhi.io/node 24.22.1@ (.nvmrc) - dhi.io/node 24.22.1-debian13-dev@20cf36e (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.1-debian13@32a2fa6 (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.1-debian13-dev@20cf36e (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.1-debian13-dev@20cf36e (docker/images/Dockerfile.exporter) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .nvmrc | 2 +- docker/devenv/Dockerfile | 8 ++++---- docker/imagemagick/Dockerfile | 4 ++-- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.nvmrc b/.nvmrc index cdd6de7dff..474d750b50 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.22.0 +v24.22.1 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index ba65456579..f24b5f91e6 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.22.0 \ +ENV NODE_VERSION=v24.22.1 \ PNPM_VERSION=12.14.0 \ PATH=/opt/node/bin:$PATH @@ -76,14 +76,14 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - NODE_SHA256='b7e7991d5407815adfa74a2a9cd4db683552c8ce32ff4a295c22e972849c3f5d'; \ + NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - NODE_SHA256='28ea0a438de9a13bd7891fd758eee6c7819ee8af5442dfd8d45bd65e56d4228c'; \ + NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ ;; \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index a0323a1c63..f875c09a95 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13@sha256:20079b51710f0397da5e056bfc7156b6aafc7ff3aa4ef4cbcb0b0f1df99cd8c4 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 3d74f8be43..20fa09f50e 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:7b1d6522b9a4e72e2ca3537c031ca3650c654ec81c181447bf952069eb827226 AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 4bbb65594f..de837a867e 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 8213d88490..26f84d4640 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:10c0037149e51fec759fb6006d5ab8bc8343f0d718c86841bd510848cc5eeef4 +FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f73a35696b..ebd3f43e36 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 AS build +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.0-debian13@sha256:c6ddacf90691f33734423b06682a63b27abeeafee294830455e91c79614ece8b AS image +FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index 1590dcf6a1..dbb21d7903 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.22.0-debian13-dev@sha256:39f55475c45417de6a7be329834ce5b19bdda6c347ecd98e42b89d76bf4afa82 +FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ From 033b9a336208886b7e6ba06d64561e175756f064 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:53:02 +0200 Subject: [PATCH 139/325] :arrow_up: update GitHub Actions (#12598) - actions/create-github-app-token v3.3.0 (.github/workflows/auto-label.yml) - pnpm/setup v3.1.1 (.github/workflows/plugins-deploy-styles-doc.yml) - pnpm/setup v3.1.1 (.github/workflows/plugins-deploy-api-doc.yml) - zizmor 1.31.1 (.github/workflows/lint-workflows.yml) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .github/workflows/auto-label.yml | 2 +- .github/workflows/lint-workflows.yml | 2 +- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-styles-doc.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 84615efe4b..65dcc51875 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Generate GitHub App token id: triage-app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ secrets.TRIAGE_APP_CLIENT_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index eb3ca73ed3..a9ad7d055c 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -34,7 +34,7 @@ permissions: {} env: ACTIONLINT_VERSION: '1.7.12' - ZIZMOR_VERSION: '1.30.1' + ZIZMOR_VERSION: '1.31.1' jobs: lint: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index d9e7393859..bcbacd9b13 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -48,7 +48,7 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins runtime: node@24.22.0 diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 4f4d23e271..d64a702184 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -46,7 +46,7 @@ jobs: # No dependency cache here: a deploy must not restore a cache that # other workflows can write to. - name: Setup pnpm + Node.js - uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0 + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins runtime: node@24.22.0 From 76a83387d9d2753a78b13f067f314ebceb788df6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 23 Oct 2026 12:36:11 +0200 Subject: [PATCH 140/325] :arrow_up: Update JVM, Node, pnpm and devenv dependencies (#12600) Move the backend JDK to its latest maintenance build within Java 26 and refresh the dependency versions pinned by hand in the development and production images. - Backend: Zulu JDK 26.0.2 to 26.0.2.1. The jlink module list keeps jdk.management, so the prometheus process metrics stay exported. - Node.js 24.22.1 in .nvmrc, the development image tarball and the two plugins deploy workflows that pin it. - pnpm 12.15.0 in the four Dockerfiles, the 34 packageManager fields and the eleven lockfiles. - Development tools: clj-kondo 2026.10.20, Babashka 1.13.226, Pixi 0.83.0, GitHub CLI 2.103.0, AWS CLI 2.38.5 and uv 0.13.5. - Infra services: RustFS 1.0.3, Mailpit v1.32.0, and Valkey pinned to 8.1.10 instead of the floating 8.1 tag. Fix SERENA_UPDATE_VERSION in the compose file. A value there makes the start script reinstall that Serena release on every start, overriding the one baked into the image; it was replacing 1.7.0 with 1.6.1. The hardened base image updates are left to Renovate. Closes #12599 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh --- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .serena/memories/devenv/core.md | 42 ++++++ .serena/memories/workflow/updating-pnpm.md | 4 +- backend/package.json | 2 +- backend/pnpm-lock.yaml | 124 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 124 +++++++++--------- docker/devenv/Dockerfile | 22 ++-- docker/devenv/docker-compose.infra.yml | 6 +- docker/devenv/docker-compose.main.yml | 2 +- docker/images/Dockerfile.backend | 8 +- docker/images/Dockerfile.exporter | 6 +- docker/images/Dockerfile.mcp | 6 +- docker/images/Dockerfile.media-processor | 6 +- docs/package.json | 2 +- docs/pnpm-lock.yaml | 124 +++++++++--------- exporter/package.json | 2 +- exporter/pnpm-lock.yaml | 124 +++++++++--------- frontend/package.json | 2 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/ui/package.json | 2 +- frontend/pnpm-lock.yaml | 124 +++++++++--------- frontend/text-editor/package.json | 2 +- library/package.json | 2 +- library/pnpm-lock.yaml | 124 +++++++++--------- mcp/package.json | 2 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 2 +- mcp/pnpm-lock.yaml | 124 +++++++++--------- media-processor/package.json | 2 +- media-processor/pnpm-lock.yaml | 124 +++++++++--------- package.json | 2 +- .../apps/colors-to-tokens-plugin/package.json | 2 +- .../apps/composable-test-suite/package.json | 2 +- plugins/apps/contrast-plugin/package.json | 2 +- .../apps/create-palette-plugin/package.json | 2 +- plugins/apps/e2e/package.json | 2 +- plugins/apps/example-styles/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- .../apps/plugin-api-test-suite/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- .../apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- plugins/package.json | 2 +- plugins/pnpm-lock.yaml | 124 +++++++++--------- pnpm-lock.yaml | 124 +++++++++--------- render-wasm/package.json | 2 +- render-wasm/pnpm-lock.yaml | 124 +++++++++--------- 56 files changed, 790 insertions(+), 748 deletions(-) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index bcbacd9b13..f513ba6af9 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -51,7 +51,7 @@ jobs: uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins - runtime: node@24.22.0 + runtime: node@24.22.1 install: false - name: Install deps diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index d64a702184..65ed5c80f3 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -49,7 +49,7 @@ jobs: uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: working-directory: plugins - runtime: node@24.22.0 + runtime: node@24.22.1 install: false - name: Install deps diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 675f038b76..d048aff4d1 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -77,3 +77,45 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi - `start-devenv` / `log-devenv` / `drop-devenv`: legacy paths around ws0 + shared infra. `drop-devenv` never removes volumes. `exporter/scripts/run` and `wait-and-start.sh` source `backend/scripts/_env` then `_env.local` if present. + +## Image and dependency pins + +Two Dockerfile trees, both multi-arch (`linux/amd64,linux/arm64`): +`docker/devenv` (dev) and `docker/images` + `docker/imagemagick` (prod). + +- Hand-pinned versions, one source each: + - `docker/devenv/Dockerfile`: `NODE_VERSION` (+ per-arch `NODE_SHA256`), + `PNPM_VERSION` (+ `PNPM_SHA256`), `CADDY_VERSION`, `CLOJURE_VERSION`, + Zulu JDK URL + `ESUM`, `RUSTUP_VERSION`/`RUST_VERSION`/ + `EMSCRIPTEN_VERSION`, `CLJKONDO_VERSION`, `BABASHKA_VERSION`, + `CLJFMT_VERSION`, `PIXI_VERSION`, `GITHUB_CLI_VERSION`, + `AWS_CLI_VERSION` (+ `BINARY_SHA256`), `UV_VERSION`, `SERENA_VERSION`, + `postgresql-client-18`, and the `penpotapp/imagemagick:` + `COPY --from`. + - `docker/images/Dockerfile.backend`: Zulu JDK URL + `ESUM`. The `jlink` + `--add-modules` list must keep `jdk.management` + (`mem:backend/subtleties`). + - `docker/images/Dockerfile.{exporter,mcp,media-processor}`: `ARG + PNPM_VERSION` (+ arch SHAs, same values as devenv). +- Node and pnpm must agree in five places: `.nvmrc`, the devenv tarball pin, + the two `plugins-deploy-*` workflows that set `runtime: node@`, and + the four Dockerfile pins. pnpm procedure: `mem:workflow/updating-pnpm`. +- `dhi.io` base images are pinned `tag@sha256:`; Renovate moves + them (`penpot-renovate[bot]`, commits titled `:arrow_up: Update DHI base + images`). Tag and digest move together: a new tag with the old digest still + pulls the old image, and the digest must be the multi-arch index, not a + per-platform one. +- Every hand-pinned checksum is per architecture; one wrong value breaks half + of the multi-arch build. +- `SERENA_UPDATE_VERSION` in `docker-compose.main.yml` must stay empty. Any + value makes `start-tmux.sh` reinstall that Serena release on every start, + overriding the `SERENA_VERSION` baked into the image (it silently replaced + 1.7.0 with 1.6.1). Set it only to test one specific release on purpose. +- Infra images live in `docker-compose.infra.yml`: `rustfs/rustfs` (owns the + S3 data volume), `axllent/mailpit` (SQLite in a volume), `valkey/valkey`, + `postgres:18.6`, `rroemhild/test-openldap`. Keep the volume names stable + when bumping them; replacement services get new volumes and leave the old + data untouched. +- Build locally: `docker build -f docker/devenv/Dockerfile docker/devenv/` + and `cd docker/images && ./build.sh --local`. CI: + `build-docker-devenv.yml`, `build-docker.yml`. diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index cbaafdeffc..3baf21b358 100644 --- a/.serena/memories/workflow/updating-pnpm.md +++ b/.serena/memories/workflow/updating-pnpm.md @@ -23,7 +23,7 @@ file (never pipe tool output through filters). - The store survives `node_modules` cleans. It is content-addressed and integrity-verified, so it cannot go stale; staleness lives in node_modules. Only `scripts/clean-node-modules --store` removes it. -- Every first-party `package.json` (35 files: workspace roots plus +- Every first-party `package.json` (34 files: workspace roots plus members) must carry a `packageManager` field with the identical `pnpm@+sha512.` value. Do not let them drift. `external/` (vendored trees with their own lifecycles), `.opencode/`, @@ -60,7 +60,7 @@ file (never pipe tool output through filters). 3. From the repo root, on a host whose system pnpm is the target version (rebuilt devenv), run `scripts/sync-pnpm-version`. It resolves the integrity hash via `pnpm view` + node (no npm, no corepack, no - python3) and stamps the identical field into all 35 files, replacing + python3) and stamps the identical field into all 34 files, replacing only the value line (each file keeps its own indent) and inserting the key after `"name"`/`"version"` where missing. Explicit version instead: `scripts/sync-pnpm-version `. The explicit form works diff --git a/backend/package.json b/backend/package.json index a2813b9649..f877761df9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 8cef22d73a..ec457e4c24 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index 2fa9d1fcdd..2c63d73b31 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 0df77b1194..182b225f53 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index f24b5f91e6..81fae7a791 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -67,7 +67,7 @@ RUN set -ex; \ FROM base AS setup-node ENV NODE_VERSION=v24.22.1 \ - PNPM_VERSION=12.14.0 \ + PNPM_VERSION=12.15.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -78,14 +78,14 @@ RUN set -eux; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -226,13 +226,13 @@ RUN set -eux; \ FROM base AS setup-utils -ENV CLJKONDO_VERSION=2026.08.04 \ - BABASHKA_VERSION=1.13.225 \ +ENV CLJKONDO_VERSION=2026.10.20 \ + BABASHKA_VERSION=1.13.226 \ CLJFMT_VERSION=0.16.6 \ - PIXI_VERSION=0.81.0 \ - GITHUB_CLI_VERSION=2.102.0 \ - AWS_CLI_VERSION=2.37.9 \ - UV_VERSION=0.12.23 \ + PIXI_VERSION=0.83.0 \ + GITHUB_CLI_VERSION=2.103.0 \ + AWS_CLI_VERSION=2.38.5 \ + UV_VERSION=0.13.5 \ UV_TOOL_DIR=/opt/uv/tools \ UV_TOOL_BIN_DIR=/opt/utils/bin \ UV_PYTHON_INSTALL_DIR=/opt/uv/python \ @@ -347,11 +347,11 @@ RUN set -ex; \ case "${ARCH}" in \ aarch64|arm64) \ BINARY_ARCH="aarch64"; \ - BINARY_SHA256="e7d2cca3622af4765871fe9ce7eeed03acd09dca10a99ebc46e92f79d78765c0"; \ + BINARY_SHA256="07711a936e9d271045ab937d2c2c5390c25f419e4ba9e25adb9e996031e3f6be"; \ ;; \ amd64|x86_64) \ BINARY_ARCH="x86_64"; \ - BINARY_SHA256="6b3a6a3d7bb3997928f0bdf7b866914224abf242c2e54e1dcebbe84bee64f356"; \ + BINARY_SHA256="5def7d1b7e55d892f9a381bb2cb2081a685712464be0d36d5d706af0ac48d0d3"; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/devenv/docker-compose.infra.yml b/docker/devenv/docker-compose.infra.yml index 6a876b36ad..a5c9a7d5b8 100644 --- a/docker/devenv/docker-compose.infra.yml +++ b/docker/devenv/docker-compose.infra.yml @@ -15,7 +15,7 @@ volumes: services: rustfs: - image: "rustfs/rustfs:1.0.0" + image: "rustfs/rustfs:1.0.3" command: /data volumes: @@ -68,7 +68,7 @@ services: - postgres valkey: - image: valkey/valkey:8.1 + image: valkey/valkey:8.1.10 restart: always command: valkey-server --save 120 1 --loglevel warning volumes: @@ -79,7 +79,7 @@ services: - valkey mailer: - image: axllent/mailpit:v1.31.1 + image: axllent/mailpit:v1.32.0 restart: always volumes: - "mailpit_data:/data" diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index 491c76b729..219e348a56 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -98,7 +98,7 @@ services: # Agentic devenv: set to a PyPI release version/tag to update Serena on startup, # leave empty to skip update and use the version baked into the image. - - SERENA_UPDATE_VERSION=1.6.1 + - SERENA_UPDATE_VERSION= - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 20fa09f50e..32e8c2c1fa 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -27,12 +27,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='5b222fce0b7076a10ac7ae3b1009a6c2caf4f35bc4e81de72010af6750c5e146'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_aarch64.tar.gz'; \ + ESUM='8ff07320636ef5b30f0d7e16687932df52255c3ef3ffd920a6aae3723511ce7f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='4b7c114917aebd0fc6284fc7111245d7747a4d9603bd12d86b384b1abc9d575d'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_x64.tar.gz'; \ + ESUM='c6b810ca659e3293205444f47ac4af163396fd6d025c51fd5f767e628b2ceede'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.203-ca-jdk26.0.2.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index de837a867e..97daca2dfe 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -95,17 +95,17 @@ WORKDIR /opt/penpot/exporter # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); the generated ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.14.0 +ARG PNPM_VERSION=12.15.0 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index ebd3f43e36..f018302d33 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.14.0 +ARG PNPM_VERSION=12.15.0 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \ @@ -20,11 +20,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index dbb21d7903..ee0694bc34 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -58,7 +58,7 @@ WORKDIR /opt/penpot/media-processor # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.14.0 +ARG PNPM_VERSION=12.15.0 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install gzip; \ @@ -67,11 +67,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index 4897ce0855..076c461693 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^10.0.0", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 10afcffde5..460dc46c55 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index d4dccd7091..40c8f6e923 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index ec7e2177a1..843416db40 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index f8adeafd03..4e1433e206 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index aa187debb2..293e69d269 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 103baefbe2..3eb605929d 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "node --test \"test/*.test.js\"" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index b8269621d3..cd8c59aad1 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5f1dad88cd..6a0901cc8c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 06cd1124a0..02cbcf2349 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^5.0.2" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/library/package.json b/library/package.json index af0488c1df..e41f4a6480 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0", "license": "MPL-2.0", "author": "KALEIDOS SUBSIDIARY SL", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index cf2b0d0663..d8a67b195d 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index c378c46e94..a62d084e1f 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.9" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 45452fe567..b87f8ce17b 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 7e937c34fb..ffa4f3837a 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index b1e5fee3f3..ee816a759b 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 42334076f4..7b7f98e50e 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 3b91ada0bd..4a79f0ae66 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "dependencies": { "express": "^5.2.1", "multer": "^2.4.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 79ea794f43..147a963c3e 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 20591c84cf..05df2e97cd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index fa28b3aad9..a762e7494c 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 5257d9f075..1f21539f19 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -27,5 +27,5 @@ "vite": "^8.3.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 73700d96b6..8402969c20 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 419b52fa7b..de963120f4 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 46941b0c59..97405221de 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index af7a4d9c58..f1fc7219c9 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 69226e5f40..841c979c14 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 3dfe909673..5868285101 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index d4c72366d0..1c6ba084c1 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -20,5 +20,5 @@ "devDependencies": { "playwright": "^1.63.0" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 23e3d77b98..3e225d2f41 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index b715d220df..83d30f08a9 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 66bdbad7f1..90faf2acea 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 180320699b..b77a35b327 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 148f002897..87546804f9 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index 7fbd7f2fa7..f40741cf5e 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 985920cc4b..aab41a3eaf 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/package.json b/plugins/package.json index 3da12fffea..f6b6d8901c 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index a370bad9b2..faf9371961 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b01fbc82..03d4f60051 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index fea9914702..96253a6356 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 87a062b5c1..9ef64d85bd 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.14.0 - version: 12.14.0 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.14.0': - resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.14.0': - resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.14.0': - resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.14.0': - resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.14.0': - resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.14.0': - resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.14.0': - resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.14.0': - resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.14.0': - resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.14.0': - resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.14.0': - resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.14.0': - resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.14.0': - resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.14.0': - resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.14.0: - resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} - engines: {node: '>=18.*'} + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.14.0': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.14.0': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.14.0': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.14.0': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.14.0': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.14.0': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.14.0': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.14.0': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.14.0': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.14.0': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.14.0': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.14.0': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.14.0': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.14.0': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.14.0: + pnpm@12.15.0: optionalDependencies: - '@pnpm/exe.android-arm64': 12.14.0 - '@pnpm/exe.android-x64': 12.14.0 - '@pnpm/exe.darwin-arm64': 12.14.0 - '@pnpm/exe.darwin-x64': 12.14.0 - '@pnpm/exe.freebsd-x64': 12.14.0 - '@pnpm/exe.linux-arm64': 12.14.0 - '@pnpm/exe.linux-arm64-musl': 12.14.0 - '@pnpm/exe.linux-ppc64': 12.14.0 - '@pnpm/exe.linux-riscv64': 12.14.0 - '@pnpm/exe.linux-s390x': 12.14.0 - '@pnpm/exe.linux-x64': 12.14.0 - '@pnpm/exe.linux-x64-musl': 12.14.0 - '@pnpm/exe.win32-arm64': 12.14.0 - '@pnpm/exe.win32-x64': 12.14.0 + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 --- lockfileVersion: '9.0' From 2045fc490064839b0582fbfa8d0538500305758f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 23 Oct 2026 13:00:53 +0200 Subject: [PATCH 141/325] :bug: Keep text image fills in place when pasting (#12552) A text image fill was always laid over the whole text box, so a pasted image span showed another part of the picture in a box of another size. Text image fills now honour the image transform, as shape fills do: the image covers the transform rect instead of the whole box. Paste gives each pasted image span a transform that keeps the image at the same pixel offset from the span, like the gradients. The SVG export emits one linked image per span transform, rather than one per image id, and the style panel keeps the transform it reads back. AI-assisted-by: claude-opus-5-5 --- .../app/main/data/workspace/clipboard.cljs | 2 +- .../src/app/main/data/workspace/texts.cljs | 24 +++--- frontend/src/app/render_wasm/text_editor.cljs | 17 ++-- frontend/src/app/render_wasm/text_paste.cljs | 25 ++++-- .../render_wasm/text_paste_test.cljs | 39 +++++++-- render-wasm/src/render/images.rs | 10 +-- render-wasm/src/render/svg/fixtures.rs | 44 +++++++--- render-wasm/src/render/svg/tests.rs | 45 ++++++++++ render-wasm/src/render/svg/text.rs | 26 ++---- render-wasm/src/shapes/fills.rs | 86 ++++++++++++++++--- render-wasm/src/shapes/text.rs | 12 +-- 11 files changed, 239 insertions(+), 91 deletions(-) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 4796ab969c..6b560ccd6f 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1116,7 +1116,7 @@ (dwsh/create-and-add-shape :text x y shape (when skip-edition? {:skip-edition? true}))) (if skip-edition? - (rx/of (dwtxt/move-new-text-gradients id payload) + (rx/of (dwtxt/move-new-text-fills id payload) (dwwt/resize-wasm-text-debounce id {:undo-group id :undo-id undo-id})) (rx/of (dwu/commit-undo-transaction undo-id)))))))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 8ac273e1d2..c50af92813 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1708,8 +1708,8 @@ :fragment fragment})))) (defn- target-geometry - "Where the gradient spans of `pasted`, inserted at `start`, begin in `shape` with - `content`, laid over the box it gets once resized to its text; nil when none." + "Where the gradient and image spans of `pasted`, inserted at `start`, begin in `shape` + with `content`, laid over the box it gets once resized to its text; nil when none." [{:keys [id grow-type]} content pasted start] (when-let [geometry (wasm.text-editor/content-geometry id pasted start)] (if (= :fixed grow-type) @@ -1728,23 +1728,23 @@ :height height}))))] (update geometry :origins (partial mapv (partial mapv #(some-> % fit)))))))) -(defn- move-pasted-gradients - "`content`, the shape's after restyling the paste, with the pasted gradients - keeping their place on their spans. Pushes the result to WASM." +(defn- move-pasted-fills + "`content`, the shape's after restyling the paste, with the pasted gradients and + images keeping their place on their spans. Pushes the result to WASM." [shape content {:keys [geometry] :as paste} start] (let [pasted (:content paste) target (when (some? geometry) (target-geometry shape content pasted start))] (if (some? target) - (let [content (text-paste/move-gradients content pasted start geometry target)] + (let [content (text-paste/move-span-fills content pasted start geometry target)] (wasm.api/set-shape-text-content (:id shape) content) content) content))) -(defn move-new-text-gradients - "Event moving the gradients of `payload`, just pasted on the canvas as the text - `id`, so they keep their place on their spans." +(defn move-new-text-fills + "Event moving the gradients and images of `payload`, just pasted on the canvas as the + text `id`, so they keep their place on their spans." [id {:keys [geometry]}] - (ptk/reify ::move-new-text-gradients + (ptk/reify ::move-new-text-fills ptk/WatchEvent (watch [_ state _] (let [{:keys [content] :as shape} (dsh/lookup-shape state id) @@ -1756,7 +1756,7 @@ (if (some? target) (rx/of (dwsh/update-shapes [id] - #(update % :content text-paste/move-gradients content start geometry target) + #(update % :content text-paste/move-span-fills content start geometry target) {:attrs #{:content}})) (rx/empty)))))) @@ -1781,7 +1781,7 @@ (wasm.text-editor/text-editor-sync-content) (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] (let [shape (dsh/lookup-shape state shape-id)] - (v3-update-editor-content shape-id (move-pasted-gradients shape content paste start))) + (v3-update-editor-content shape-id (move-pasted-fills shape content paste start))) (v3-sync-editor-content))) pending? diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 4348117ed8..ebfabdb07d 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -123,12 +123,17 @@ width (aget heap-i32 (+ fill-u32-offset 6)) height (aget heap-i32 (+ fill-u32-offset 7))] {:fill-opacity (u8->opacity alpha) - :fill-image {:id (uuid/from-unsigned-parts a b c d) - :width width - :height height - :mtype (types.fills.impl/decode-mtype mtype) - :keep-aspect-ratio (not (zero? (bit-and flags 0x01))) - :name "sample"}}) + :fill-image (cond-> {:id (uuid/from-unsigned-parts a b c d) + :width width + :height height + :mtype (types.fills.impl/decode-mtype mtype) + :keep-aspect-ratio (not (zero? (bit-and flags 0x01))) + :name "sample"} + (not (zero? (bit-and flags 0x02))) + (assoc :transform {:x (aget heap-f32 (+ fill-u32-offset 8)) + :y (aget heap-f32 (+ fill-u32-offset 9)) + :width (aget heap-f32 (+ fill-u32-offset 10)) + :height (aget heap-f32 (+ fill-u32-offset 11))}))}) nil))) diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs index 4520327ce6..bbc5b5d512 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -127,7 +127,7 @@ content (rest (map-indexed vector fragment))))) -;; --- Gradients of text copied in Penpot +;; --- Gradients and images of text copied in Penpot (defn- move-gradient "`gradient`, in fractions of the `from` box, in fractions of the `to` box, with its @@ -146,22 +146,37 @@ (= :radial (:type gradient)) (update :width #(/ (* % w0 h1) (* h0 w1)))))) +(defn- move-image + "`image`, laid over its transform in the `from` box (the whole box without one), + with a transform in the `to` box at the same pixel offset from the span origin." + [image from to] + (let [{x0 :x y0 :y {w0 :width h0 :height} :box} from + {x1 :x y1 :y {w1 :width h1 :height} :box} to + {:keys [x y width height]} (get image :transform {:x 0 :y 0 :width 1 :height 1})] + (assoc image :transform {:x (/ (+ x1 (- (* x w0) x0)) w1) + :y (/ (+ y1 (- (* y h0) y0)) h1) + :width (/ (* width w0) w1) + :height (/ (* height h0) h1)}))) + (defn- move-fills [span from to] (update span :fills (fn [fills] (mapv #(cond-> % (some? (:fill-color-gradient %)) - (update :fill-color-gradient move-gradient from to)) + (update :fill-color-gradient move-gradient from to) + + (some? (:fill-image %)) + (update :fill-image move-image from to)) fills)))) (defn- sized? [{{:keys [width height]} :box}] (and (pos? width) (pos? height))) -(defn move-gradients - "`content` with the gradients of `pasted` (the content inserted at `start`) moved - from the `source` geometry to the `target` one (see `text-editor/content-geometry`)." +(defn move-span-fills + "`content` with the gradients and images of `pasted`, inserted at `start`, moved from + the `source` geometry to the `target` one (see `text-editor/content-geometry`)." [content pasted start source target] (let [entries (map vector (flatten (text-editor/span-ranges pasted start)) diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs index c9bc145647..a52c1f5e2e 100644 --- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -213,8 +213,8 @@ (let [linear (gradient-fill {:start-x 0.25 :start-y 0 :end-x 0.75 :end-y 1}) pasted (content [(span "HELLO" linear)]) target (content [(span "abc") (span "HELLO" linear)]) - result (text-paste/move-gradients target pasted {:para 0 :offset 3} - source-geometry target-geometry)] + result (text-paste/move-span-fills target pasted {:para 0 :offset 3} + source-geometry target-geometry)] (t/testing "points move into the target box at the same pixel offsets" (t/is (= {:start-x 0.1 :start-y 0.5 :end-x 1.1 :end-y 1} (select-keys (pasted-gradient result) [:start-x :start-y :end-x :end-y])))) @@ -227,8 +227,8 @@ (let [radial (gradient-fill {:type :radial :start-x 0.5 :start-y 0.5 :end-x 0.5 :end-y 1}) pasted (content [(span "HELLO" radial)]) target (content [(span "abc") (span "HELLO" radial)]) - result (text-paste/move-gradients target pasted {:para 0 :offset 3} - source-geometry target-geometry)] + result (text-paste/move-span-fills target pasted {:para 0 :offset 3} + source-geometry target-geometry)] ;; 200x40 is 5:1 and 100x80 is 5:4, so the width grows 4 times. (t/is (= 4 (:width (pasted-gradient result)))))) @@ -238,12 +238,12 @@ pasted (content [(span "red" red) (span "grad" linear)]) target (content [(span "red" red) (span "grad" linear)])] (t/testing "a span with no origin keeps its gradient" - (t/is (= target (text-paste/move-gradients + (t/is (= target (text-paste/move-span-fills target pasted {:para 0 :offset 0} {:origins [[nil nil]]} {:origins [[nil nil]]})))) (t/testing "an empty box leaves the gradients as they were" - (t/is (= target (text-paste/move-gradients + (t/is (= target (text-paste/move-span-fills target pasted {:para 0 :offset 0} {:origins [[nil {:x 0 :y 0 :box source-box}]]} {:origins [[nil {:x 0 :y 0 :box {:width 0 :height 80}}]]})))))) @@ -256,9 +256,34 @@ [{:x 0 :y 0 :box {:width 200 :height 40}}]]} target {:origins [[{:x 0 :y 0 :box {:width 400 :height 80}}] [{:x 0 :y 40 :box {:width 400 :height 80}}]]} - result (text-paste/move-gradients pasted pasted {:para 0 :offset 0} source target) + result (text-paste/move-span-fills pasted pasted {:para 0 :offset 0} source target) points #(select-keys (-> % :fills first :fill-color-gradient) [:start-x :start-y :end-x :end-y])] (t/is (= [{:start-x 0.125 :start-y 0 :end-x 0.125 :end-y 0.25} {:start-x 0.25 :start-y 0.5 :end-x 0.25 :end-y 1}] (map (comp points first) (spans-of result)))))) + +;; --- Images + +(defn- image-fill [image] + {:fills [{:fill-image (merge {:id #uuid "00000000-0000-0000-0000-000000000042" + :width 400 :height 200 :mtype "image/png"} + image)}]}) + +(defn- pasted-image-transform [content] + (-> (spans-of content) first second :fills first :fill-image :transform)) + +(t/deftest images-keep-their-offset-from-the-span + (let [move (fn [image] + (let [fill (image-fill image)] + (pasted-image-transform + (text-paste/move-span-fills (content [(span "abc") (span "HELLO" fill)]) + (content [(span "HELLO" fill)]) + {:para 0 :offset 3} + source-geometry target-geometry))))] + (t/testing "an image over the whole source box gets a transform over the same pixels" + (t/is (= {:x -0.4 :y 0.5 :width 2 :height 0.5} + (move {})))) + (t/testing "an image with a transform keeps its pixel rect" + (t/is (= {:x 0.1 :y 0.5 :width 1 :height 0.5} + (move {:transform {:x 0.25 :y 0 :width 0.5 :height 1}})))))) diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 5b59b3620a..bcbf557d0c 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -21,15 +21,7 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect { } pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect { - match image_fill.transform() { - Some(tf) => MathRect::from_xywh( - container.left + tf.x * container.width(), - container.top + tf.y * container.height(), - tf.width * container.width(), - tf.height * container.height(), - ), - None => *container, - } + image_fill.dest_rect(container) } pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect { diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index db7759102b..a33d5d258e 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -335,23 +335,39 @@ pub(super) fn add_text_with_fills( text: &str, font_size: f32, fills: Vec, +) { + add_text_with_spans(pool, id, (l, t, r, b), font_size, vec![(text, fills)]); +} + +/// Fixed text with one paragraph of `spans`, each a text and its fills. +pub(super) fn add_text_with_spans( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + font_size: f32, + spans: Vec<(&str, Vec)>, ) { let bounds = skia::Rect::from_ltrb(l, t, r, b); let mut content = TextContent::new(bounds, GrowType::Fixed); let line_height = 1.2; - let span = TextSpan::new( - text.to_string(), - FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), - font_size, - line_height, - 0.0, - None, - None, - TextDirection::LTR, - 400, - Uuid::nil(), - fills, - ); + let spans = spans + .into_iter() + .map(|(text, fills)| { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + line_height, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + fills, + ) + }) + .collect(); content.add_paragraph(Paragraph::new( TextAlign::Left, TextDirection::LTR, @@ -359,7 +375,7 @@ pub(super) fn add_text_with_fills( None, line_height, 0.0, - vec![span], + spans, )); let shape = pool.add_shape(id); diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 02eda33b79..87eeb29fb4 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -2957,6 +2957,51 @@ fn exports_image_fill_on_text() { insta::assert_snapshot!(svg); } +#[test] +fn exports_one_image_per_text_image_transform() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + let image = |x: f32| { + Fill::Image(ImageFill::new_with_transform( + image_id, + 255, + 200, + 100, + true, + Some(ImageFillTransform { + x, + y: 0.0, + width: 0.5, + height: 1.0, + }), + )) + }; + add_text_with_spans( + &mut pool, + id, + (0.0, 0.0, 400.0, 100.0), + 60.0, + vec![("HO", vec![image(0.0)]), ("LA", vec![image(0.5)])], + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_eq!(svg.matches(" skia_safe::Rect { - let image_width = image_fill.width() as f32; - let image_height = image_fill.height() as f32; - let scale_x = selrect.width() / image_width; - let scale_y = selrect.height() / image_height; - let scale = scale_x.max(scale_y); - let scaled_width = image_width * scale; - let scaled_height = image_height * scale; - let pos_x = selrect.left() - (scaled_width - selrect.width()) / 2.0; - let pos_y = selrect.top() - (scaled_height - selrect.height()) / 2.0; - skia_safe::Rect::from_xywh(pos_x, pos_y, scaled_width, scaled_height) -} - fn push_glyph_clip( builder: &mut SvgLayerCanvas, element: &Shape, @@ -365,12 +351,12 @@ fn linked_image_fills_at_layer<'a>( layer: usize, shared: &RenderResources, ) -> Vec<&'a ImageFill> { - let mut out = Vec::new(); - let mut seen = HashSet::new(); + // Spans can share an image with different transforms: one element each. + let mut out: Vec<&ImageFill> = Vec::new(); for paragraph in text_content.paragraphs() { for span in paragraph.children() { if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) { - if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) { + if shared.images.source_url(&img.id()).is_some() && !out.contains(&img) { out.push(img); } } @@ -412,7 +398,7 @@ fn emit_text_image_fill( let clip_id = builder.unique("txtimgclip"); let text_content = shape.get_text_content().new_bounds(shape.selrect()); let mut paragraph_builders = - text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id()); + text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill); let canvas = builder.new_fragment(); { @@ -428,7 +414,7 @@ fn emit_text_image_fill( emit_linked_image_element( builder, image_fill, - shape.selrect(), + image_fill.dest_rect(&shape.selrect()), &href, Some(&clip_id), true, diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index c819579de0..15afbbbba7 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -212,6 +212,35 @@ impl ImageFill { pub fn transform(&self) -> Option<&ImageFillTransform> { self.transform.as_ref() } + + /// The part of `container` the image is laid over: its transform, else all of it. + pub fn dest_rect(&self, container: &Rect) -> Rect { + match &self.transform { + Some(tf) => Rect::from_xywh( + container.left + tf.x * container.width(), + container.top + tf.y * container.height(), + tf.width * container.width(), + tf.height * container.height(), + ), + None => *container, + } + } + + /// Where the image is drawn to cover its dest rect in `container`, centered. + pub fn cover_rect(&self, container: &Rect) -> Rect { + let dest = self.dest_rect(container); + let image_width = self.width as f32; + let image_height = self.height as f32; + let scale = (dest.width() / image_width).max(dest.height() / image_height); + let scaled_width = image_width * scale; + let scaled_height = image_height * scale; + Rect::from_xywh( + dest.left - (scaled_width - dest.width()) / 2.0, + dest.top - (scaled_height - dest.height()) / 2.0, + scaled_width, + scaled_height, + ) + } } #[derive(Debug, Clone, PartialEq, Copy)] @@ -310,20 +339,14 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option let sampling_options = skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest); - // FIXME no image ratio applied, centered to the current rect + // Covers the dest rect (transform, else the whole box), centered. + // keep_aspect_ratio is not applied: the image always covers. let tile_modes = (skia::TileMode::Clamp, skia::TileMode::Clamp); - let image_width = image_fill.width as f32; - let image_height = image_fill.height as f32; - let scale_x = bounding_box.width() / image_width; - let scale_y = bounding_box.height() / image_height; - let scale = scale_x.max(scale_y); - let scaled_width = image_width * scale; - let scaled_height = image_height * scale; - let pos_x = bounding_box.left() - (scaled_width - bounding_box.width()) / 2.0; - let pos_y = bounding_box.top() - (scaled_height - bounding_box.height()) / 2.0; + let cover = image_fill.cover_rect(bounding_box); + let scale = cover.width() / image_fill.width as f32; let mut matrix = skia::Matrix::new_identity(); - matrix.pre_translate((pos_x, pos_y)); + matrix.pre_translate((cover.left, cover.top)); matrix.pre_scale((scale, scale), None); let opacity = image_fill.opacity(); @@ -394,3 +417,44 @@ pub fn set_paint_fill(paint: &mut Paint, fill: &Fill, bounding_box: &Rect, remov paint.set_shader(shader); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn image_fill(transform: Option) -> ImageFill { + ImageFill::new_with_transform(Uuid::nil(), 255, 100, 50, true, transform) + } + + #[test] + fn an_image_without_transform_covers_the_whole_container() { + let container = Rect::from_xywh(10.0, 20.0, 100.0, 100.0); + + assert_eq!(image_fill(None).dest_rect(&container), container); + // 100x50 scaled by 2 to fill the height, centered horizontally. + assert_eq!( + image_fill(None).cover_rect(&container), + Rect::from_xywh(-40.0, 20.0, 200.0, 100.0) + ); + } + + #[test] + fn an_image_with_transform_covers_its_part_of_the_container() { + let container = Rect::from_xywh(10.0, 20.0, 200.0, 100.0); + let fill = image_fill(Some(ImageFillTransform { + x: 0.5, + y: -0.5, + width: 0.5, + height: 1.0, + })); + + assert_eq!( + fill.dest_rect(&container), + Rect::from_xywh(110.0, -30.0, 100.0, 100.0) + ); + assert_eq!( + fill.cover_rect(&container), + Rect::from_xywh(60.0, -30.0, 200.0, 100.0) + ); + } +} diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 49b8fc4861..a8328be0d1 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -883,18 +883,18 @@ impl TextContent { } /// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is - /// the given image — used as an SVG `` for linked image fills. + /// `image_fill` — used as an SVG `` for linked image fills. pub fn paragraph_builder_group_opaque_for_image_layer( &self, layer_from_bottom: usize, - image_id: Uuid, + image_fill: &shapes::ImageFill, ) -> Vec { self.paragraph_builders( None, false, None, None, - Some((layer_from_bottom, image_id)), + Some((layer_from_bottom, image_fill)), None, ) } @@ -905,7 +905,7 @@ impl TextContent { opaque: bool, align_override: Option, fill_layer: Option, - opaque_image_layer: Option<(usize, Uuid)>, + opaque_image_layer: Option<(usize, &shapes::ImageFill)>, skip_image_ids: Option<&HashSet>, ) -> Vec { let fonts = get_font_collection(); @@ -920,7 +920,7 @@ impl TextContent { let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for span in paragraph.children() { - let text_style = if let Some((layer, image_id)) = opaque_image_layer { + let text_style = if let Some((layer, image_fill)) = opaque_image_layer { let mut style = span.to_style( &self.bounds(), fallback_fonts, @@ -929,7 +929,7 @@ impl TextContent { ); let mut paint = paint::Paint::default(); match span.fills_from_bottom(layer) { - Some(shapes::Fill::Image(img)) if img.id() == image_id => { + Some(shapes::Fill::Image(img)) if img == image_fill => { paint.set_color(skia::Color::BLACK); paint.set_alpha(255); } From 8a15b0a9164548b0e0e3f5bceb5b9dc21c46c783 Mon Sep 17 00:00:00 2001 From: imagoiq Date: Fri, 23 Oct 2026 08:50:08 +0200 Subject: [PATCH 142/325] :globe_with_meridians: Add translations for: French Currently translated at 100.0% (2275 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/ --- frontend/translations/fr.po | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index a3770cdb7a..568b8efa07 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,8 +1,7 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-10-15 08:51+0000\n" -"Last-Translator: Philippe Accorsi " -"\n" +"PO-Revision-Date: 2026-10-24 06:51+0000\n" +"Last-Translator: imagoiq \n" "Language-Team: French \n" "Language: fr\n" @@ -667,7 +666,7 @@ msgstr "Dernière modification %s" #: src/app/main/ui/dashboard/import.cljs:995, src/app/main/ui/dashboard/project_menu.cljs:110 msgid "dashboard.import" -msgstr "Importer fichiers" +msgstr "Importer des fichiers Penpot" #: src/app/main/ui/dashboard/import.cljs:343, src/app/worker/import.cljs:144, src/app/worker/import.cljs:147 msgid "dashboard.import.analyze-error" From bf580e5aa7382fc4a638b07928991514da6ed6be Mon Sep 17 00:00:00 2001 From: Danial Shirali Date: Sat, 24 Oct 2026 21:40:34 +0200 Subject: [PATCH 143/325] :globe_with_meridians: Add translations for: Persian Currently translated at 98.5% (2242 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/ --- frontend/translations/fa.po | 47 +++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index b2c18a8035..3a10fa726c 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -1,15 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-09-28 08:14+0000\n" -"Last-Translator: Danial Shirali \n" -"Language-Team: Persian " -"\n" +"PO-Revision-Date: 2026-10-25 19:51+0000\n" +"Last-Translator: Danial Shirali " +"\n" +"Language-Team: Persian \n" "Language: fa\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 2026.10.dev0\n" +"X-Generator: Weblate 2026.11.dev0\n" #: src/app/main/ui/auth/register.cljs:227, src/app/main/ui/static.cljs:191, src/app/main/ui/viewer/login.cljs:119 msgid "auth.already-have-account" @@ -9372,3 +9373,39 @@ msgstr "نسخه‌های ذخیرهٔ خودکار به مدت %s روز نگه #~ msgid "viewer.header.share.placeholder" #~ msgstr "لینک اشتراک‌گذاری در اینجا ظاهر می‌شود" + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "اتصال کتابخانه‌های انتخاب شده" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "اسم فایل" + +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "فایل بعدی" + +#: src/app/main/ui/dashboard/import.cljs:459 +msgid "dashboard.import.resolve-libraries.connect-to" +msgstr "اتصال به" + +#: src/app/main/ui/dashboard/import.cljs:453 +msgid "dashboard.import.resolve-libraries.original-library" +msgstr "کتابخانه اصلی" + +#: src/app/main/ui/dashboard/import.cljs:529 +msgid "dashboard.import.summary.manually-linked" +msgstr "دستی لینک شده" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "جدید" + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "اصل" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "هنوز هیچ سازمانی وجود ندارد." From 904f456c61b396c5f9943335448cff73af3b2929 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:27:13 +0100 Subject: [PATCH 144/325] :recycle: Use DS icons in the color palette and its menu (#12444) * :recycle: Use DS icons in the color palette and its menu The scroll arrows of the color palette and the tick of the selected palette in its menu used deprecated icons; use the DS arrow and tick icons instead. The arrows are full-height scroll buttons with a fade, not DS button sizes, so they stay raw buttons. The color cells are swatches and stay as they are. Add a visual test that opens a file with 24 local colors, so the arrows show, and captures the palette before and after scrolling and with its menu open. Its fixture is the library fixture with more colors. AI-assisted-by: claude-opus-5-5 * :recycle: Move color palette styles off the deprecated styles Inline the button, flex and ellipsis mixins, move spacing, sizes and borders to DS tokens and logical properties, and resolve the color aliases to their DS tokens. The arrow and menu item colors that change on hover, disable or selection go through local variables, and the new icons take them through currentColor instead of svg selectors. The empty palette message and the menu items use the DS body-small typography, whose line-height is 1.3 instead of 1.4. The arrows keep their z-index, now commented: the left arrow comes before the positioned color list, so without it the colors would cover its fade. The menu keeps its legacy context menu z-index, also commented. color_palette.scss and color_palette_ctx_menu.scss no longer import refactor/. AI-assisted-by: claude-opus-5-5 --- .../data/workspace/get-file-many-colors.json | 423 ++++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 44 ++ .../app/main/ui/workspace/color_palette.cljs | 10 +- .../app/main/ui/workspace/color_palette.scss | 92 ++-- .../ui/workspace/color_palette_ctx_menu.cljs | 8 +- .../ui/workspace/color_palette_ctx_menu.scss | 90 ++-- 6 files changed, 561 insertions(+), 106 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-many-colors.json diff --git a/frontend/playwright/data/workspace/get-file-many-colors.json b/frontend/playwright/data/workspace/get-file-many-colors.json new file mode 100644 index 0000000000..bd2c2816c2 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-many-colors.json @@ -0,0 +1,423 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Many colors", + "~:revn": 2, + "~:modified-at": "~m1717512948250", + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:is-shared": true, + "~:version": 48, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1717512934704", + "~:data": { + "~:pages": [ + "~uc1249a66-fce0-8175-8004-7433fe4be8bd" + ], + "~:pages-index": { + "~uc1249a66-fce0-8175-8004-7433fe4be8bd": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~uc70224ec-c410-807b-8004-743400e00be8" + ] + } + }, + "~uc70224ec-c410-807b-8004-743400e00be8": { + "~#shape": { + "~:y": 255, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 279.0000000000001, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 523, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 534 + } + }, + { + "~#point": { + "~:x": 523, + "~:y": 534 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~uc70224ec-c410-807b-8004-743400e00be8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 523, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 523, + "~:y": 255, + "~:width": 279.0000000000001, + "~:height": 279, + "~:x1": 523, + "~:y1": 255, + "~:x2": 802.0000000000001, + "~:y2": 534 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 0, + "~:height": 279, + "~:flip-y": null + } + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bd", + "~:name": "Page 1" + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#187cd5", + "~:opacity": 1 + } + ], + "~:colors": { + "~uc70224ec-c410-807b-8004-74340616d000": { + "~:path": "", + "~:color": "#d84b4b", + "~:name": "color-01", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d000" + }, + "~uc70224ec-c410-807b-8004-74340616d001": { + "~:path": "", + "~:color": "#d86f4b", + "~:name": "color-02", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d001" + }, + "~uc70224ec-c410-807b-8004-74340616d002": { + "~:path": "", + "~:color": "#d8924b", + "~:name": "color-03", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d002" + }, + "~uc70224ec-c410-807b-8004-74340616d003": { + "~:path": "", + "~:color": "#d8b54b", + "~:name": "color-04", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d003" + }, + "~uc70224ec-c410-807b-8004-74340616d004": { + "~:path": "", + "~:color": "#d8d84b", + "~:name": "color-05", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d004" + }, + "~uc70224ec-c410-807b-8004-74340616d005": { + "~:path": "", + "~:color": "#b5d84b", + "~:name": "color-06", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d005" + }, + "~uc70224ec-c410-807b-8004-74340616d006": { + "~:path": "", + "~:color": "#92d84b", + "~:name": "color-07", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d006" + }, + "~uc70224ec-c410-807b-8004-74340616d007": { + "~:path": "", + "~:color": "#6fd84b", + "~:name": "color-08", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d007" + }, + "~uc70224ec-c410-807b-8004-74340616d008": { + "~:path": "", + "~:color": "#4bd84b", + "~:name": "color-09", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d008" + }, + "~uc70224ec-c410-807b-8004-74340616d009": { + "~:path": "", + "~:color": "#4bd86f", + "~:name": "color-10", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d009" + }, + "~uc70224ec-c410-807b-8004-74340616d00a": { + "~:path": "", + "~:color": "#4bd892", + "~:name": "color-11", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00a" + }, + "~uc70224ec-c410-807b-8004-74340616d00b": { + "~:path": "", + "~:color": "#4bd8b5", + "~:name": "color-12", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00b" + }, + "~uc70224ec-c410-807b-8004-74340616d00c": { + "~:path": "", + "~:color": "#4bd8d8", + "~:name": "color-13", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00c" + }, + "~uc70224ec-c410-807b-8004-74340616d00d": { + "~:path": "", + "~:color": "#4bb5d8", + "~:name": "color-14", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00d" + }, + "~uc70224ec-c410-807b-8004-74340616d00e": { + "~:path": "", + "~:color": "#4b92d8", + "~:name": "color-15", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00e" + }, + "~uc70224ec-c410-807b-8004-74340616d00f": { + "~:path": "", + "~:color": "#4b6fd8", + "~:name": "color-16", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d00f" + }, + "~uc70224ec-c410-807b-8004-74340616d010": { + "~:path": "", + "~:color": "#4b4bd8", + "~:name": "color-17", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d010" + }, + "~uc70224ec-c410-807b-8004-74340616d011": { + "~:path": "", + "~:color": "#6f4bd8", + "~:name": "color-18", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d011" + }, + "~uc70224ec-c410-807b-8004-74340616d012": { + "~:path": "", + "~:color": "#924bd8", + "~:name": "color-19", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d012" + }, + "~uc70224ec-c410-807b-8004-74340616d013": { + "~:path": "", + "~:color": "#b54bd8", + "~:name": "color-20", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d013" + }, + "~uc70224ec-c410-807b-8004-74340616d014": { + "~:path": "", + "~:color": "#d84bd8", + "~:name": "color-21", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d014" + }, + "~uc70224ec-c410-807b-8004-74340616d015": { + "~:path": "", + "~:color": "#d84bb5", + "~:name": "color-22", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d015" + }, + "~uc70224ec-c410-807b-8004-74340616d016": { + "~:path": "", + "~:color": "#d84b92", + "~:name": "color-23", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d016" + }, + "~uc70224ec-c410-807b-8004-74340616d017": { + "~:path": "", + "~:color": "#d84b6f", + "~:name": "color-24", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616d017" + } + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 92ac8dddb0..db5a1002aa 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -28,6 +28,50 @@ const setupFileWithAssets = async (workspace) => { return { fileId, pageId }; }; +test.describe("Color palette", () => { + test("Shows the scroll arrows and the palette menu", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-many-colors.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + await workspace.clickColorPalette(); + await workspace.palette.locator('[class*="palette-actions"]').click(); + await page.getByText("File library").click(); + await expect( + workspace.palette.getByRole("button", { name: "color-01" }), + ).toBeVisible(); + await page.mouse.move(640, 300); + // The arrows are a small part of the palette: compare every pixel. + await expect(workspace.palette).toHaveScreenshot("color-palette.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await workspace.palette.locator('[class*="right-arrow"]').click(); + await page.mouse.move(640, 300); + await expect(workspace.palette).toHaveScreenshot( + "color-palette-scrolled.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + await workspace.palette.locator('[class*="palette-actions"]').click(); + await expect(page.getByText("Recent colors")).toBeVisible(); + await page.mouse.move(640, 300); + await expect(page).toHaveScreenshot("color-palette-menu.png", { + clip: { x: 300, y: 400, width: 700, height: 320 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Viewport", () => { test("Shows the workspace correctly for a blank file", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 1c95587fc4..8b97d27034 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -18,9 +18,8 @@ [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.utilities.swatch :refer [swatch*]] - [app.main.ui.icons :as deprecated-icon] [app.util.color :as uc] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -186,10 +185,12 @@ :on-click on-toggle-search :aria-label (tr "workspace.assets.search")}]]) + ;; Arrows for scrolling the color palette, those buttons no not form part of the DS (when show-arrows? [:button {:class (stl/css :left-arrow) :disabled (= offset 0) - :on-click on-left-arrow-click} deprecated-icon/arrow]) + :on-click on-left-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]]) [:div {:class (stl/css :color-palette-content) :ref container :on-wheel on-scroll} @@ -212,7 +213,8 @@ (when show-arrows? [:button {:class (stl/css :right-arrow) :disabled (= offset max-offset) - :on-click on-right-arrow-click} deprecated-icon/arrow])])) + :on-click on-right-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]])])) (mf/defc recent-colors-palette* {::mf/private true} diff --git a/frontend/src/app/main/ui/workspace/color_palette.scss b/frontend/src/app/main/ui/workspace/color_palette.scss index fb0af2c527..5ac73b02a6 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.scss +++ b/frontend/src/app/main/ui/workspace/color_palette.scss @@ -4,10 +4,12 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .color-palette { - height: 100%; + block-size: 100%; display: flex; } @@ -15,63 +17,60 @@ .palette-search-collapsed { display: flex; align-items: center; - padding-inline: deprecated.$s-4; + padding-inline: var(--sp-xs); } .palette-search { - gap: deprecated.$s-4; - width: deprecated.$s-192; - min-width: deprecated.$s-192; + gap: var(--sp-xs); + inline-size: $sz-192; + min-inline-size: $sz-192; } .palette-search-collapsed { - width: deprecated.$s-32; - min-width: deprecated.$s-32; + inline-size: $sz-32; + min-inline-size: $sz-32; } +// Raw buttons on purpose: full-height scroll arrows with a fade, not DS +// button sizes. .left-arrow, .right-arrow { - @include deprecated.button-style; - @include deprecated.flex-center; + --arrow-foreground-color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; position: relative; - height: 100%; - width: deprecated.$s-24; + block-size: 100%; + inline-size: $sz-24; padding: 0; - z-index: deprecated.$z-index-5; + border: none; + background: none; + cursor: pointer; + color: var(--arrow-foreground-color); - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + // Above the color list, which is positioned and comes after the left + // arrow, so the fade covers the colors that scroll under it. + z-index: 5; &::after { content: ""; position: absolute; - z-index: deprecated.$z-index-1; - bottom: 0; - left: calc(-1 * deprecated.$s-80); - height: 100%; - width: deprecated.$s-80; - background-image: linear-gradient( - to left, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + z-index: 1; + inset-block-end: 0; + inset-inline-start: calc(-1 * px2rem(80)); + block-size: 100%; + inline-size: px2rem(80); + background-image: linear-gradient(to left, var(--color-background-primary) 0%, transparent 100%); pointer-events: none; } &:hover { - svg { - stroke: var(--button-foreground-hover); - } + --arrow-foreground-color: var(--color-accent-primary); } &:disabled { - svg { - stroke: var(--button-foreground-color-disabled); - } + --arrow-foreground-color: var(--color-foreground-disabled); &::after { background-image: none; @@ -81,19 +80,15 @@ .left-arrow { &::after { - left: deprecated.$s-24; - background-image: linear-gradient( - to right, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + inset-inline-start: $sz-24; + background-image: linear-gradient(to right, var(--color-background-primary) 0%, transparent 100%); } &:disabled::after { background-image: none; } - svg { + .arrow-icon { transform: rotate(180deg); } } @@ -108,7 +103,7 @@ display: grid; grid-auto-flow: column; grid-auto-columns: var(--color-cell-width); - gap: deprecated.$s-8; + gap: var(--sp-s); } .color-cell { @@ -121,17 +116,18 @@ grid-template-columns: 100%; grid-template-rows: auto 1fr; justify-items: center; - height: 100%; + block-size: 100%; &.no-text { - @include deprecated.flex-center; - - width: deprecated.$s-32; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-32; } } .color-palette-empty { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--palette-text-color); + color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs index 4a67f0cdb1..5085b9af12 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs @@ -12,7 +12,7 @@ [app.main.ui.components.color-bullet :as cb] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -75,7 +75,7 @@ (dm/str "(" (:total-colors library) ")")]] (when (= selected id) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s"}]])] [:div {:class (stl/css :color-sample) :style {:--bullet-size "20px"}} (for [color colors] @@ -100,7 +100,7 @@ (when (= selected :file) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s"}]])] [:div {:class (stl/css :color-sample) :style {:--bullet-size "20px"}} (for [color local-colors] @@ -123,7 +123,7 @@ (when (= selected :recent) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s"}]])] [:div {:class (stl/css :color-sample) :style {:--bullet-size "20px"}} diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss index 48fc511406..bce2b5f6d0 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss @@ -4,101 +4,91 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .palette-menu { position: absolute; - left: auto; - bottom: var(--height); - max-width: deprecated.$s-480; - padding: deprecated.$s-4; - margin: 0 0 deprecated.$s-4 0; - z-index: deprecated.$z-index-4; - border-radius: deprecated.$br-10; - background-color: var(--context-menu-background-color); + inset-inline-start: auto; + inset-block-end: var(--height); + max-inline-size: $sz-480; + padding: var(--sp-xs); + margin: 0 0 var(--sp-xs) 0; + + // Context menu level of the legacy scale, inside the palette. + z-index: 4; + border-radius: px2rem(10); + background-color: var(--color-background-tertiary); .palette-library, .recent-colors, .file-library { + --library-name-color: var(--color-foreground-secondary); + position: relative; display: flex; align-items: flex-start; - width: 100%; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - margin-bottom: deprecated.$s-4; + inline-size: 100%; + padding: var(--sp-s); + border-radius: $br-8; + margin-block-end: var(--sp-xs); &:last-child { - margin-bottom: 0; + margin-block-end: 0; } .option-wrapper { - width: 100%; + inline-size: 100%; .library-name { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--context-menu-foreground-color); + color: var(--library-name-color); display: grid; - grid-template-columns: 1fr deprecated.$s-24; + grid-template-columns: 1fr $sz-24; .lib-name-wrapper { display: flex; - max-width: deprecated.$s-400; + max-inline-size: $sz-400; .lib-name { - @include deprecated.text-ellipsis; - - max-width: deprecated.$s-380; + display: block; + max-inline-size: px2rem(380); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .lib-num { - margin-left: deprecated.$s-4; + margin-inline-start: var(--sp-xs); } } .icon-wrapper { - margin-left: deprecated.$s-4; - - @include deprecated.flex-center; - - svg { - @extend %button-icon-small; - @include deprecated.flex-center; - - stroke: var(--icon-foreground); - } + display: flex; + justify-content: center; + align-items: center; + margin-inline-start: var(--sp-xs); } } .color-sample { display: flex; flex-direction: row; - gap: deprecated.$s-8; - margin-top: deprecated.$s-4; + gap: var(--sp-s); + margin-block-start: var(--sp-xs); } } &.selected, &:hover { - .option-wrapper .library-name { - color: var(--context-menu-foreground-color-selected); - - .icon-wrapper { - @include deprecated.flex-center; - - svg { - @include deprecated.flex-center; - @extend %button-icon-small; - - stroke: var(--context-menu-foreground-color-selected); - } - } - } + --library-name-color: var(--color-foreground-primary); } &:hover { - background-color: var(--context-menu-background-color-selected); + background-color: var(--color-background-quaternary); } } } From 29b00f85350868c2d261b72e420808799197f051 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:14 +0100 Subject: [PATCH 145/325] :recycle: Use DS icons in the text palette and its menu (#12452) * :recycle: Use DS icons in the text palette and its menu The scroll arrows of the text palette and the tick of the selected palette in its menu used deprecated icons; use the DS arrow and tick icons instead. The arrows are full-height scroll buttons with a fade, not DS button sizes, so they stay raw buttons. Add a visual test that opens a file with 16 local typographies, so the arrows show, and captures the palette at rest, with the pointer over the right arrow, after scrolling and with its menu open. Its fixture is the library fixture with more typographies. AI-assisted-by: claude-opus-5-5 * :recycle: Move text palette styles off the deprecated styles Inline the button, flex and ellipsis mixins, move spacing, sizes and borders to DS tokens and logical properties, and resolve the color aliases to their DS tokens. The arrow, item and menu colors that change on hover, disable or selection go through local variables, and the new icons take them through currentColor instead of svg selectors. The three typography lines share their ellipsis rule. The .disabled ::after rule of the left arrow never matched and goes away; :disabled::after already covers it. The items, the empty message and the menu use the DS body-small typography, whose line-height is 1.3 instead of 1.4. The arrows keep their z-index, now commented, and the menu its legacy context menu one. text_palette.scss and text_palette_ctx_menu.scss no longer import refactor/. AI-assisted-by: claude-opus-5-5 * :bug: Show the text palette scroll arrows at rest The enabled scroll arrows of the text palette had no color at rest, so they only showed on hover, while the color palette arrows show in the secondary color. Give them the same rest color. AI-assisted-by: claude-opus-5-5 --- .../workspace/get-file-many-typographies.json | 465 ++++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 51 ++ .../app/main/ui/workspace/text_palette.cljs | 8 +- .../app/main/ui/workspace/text_palette.scss | 131 +++-- .../ui/workspace/text_palette_ctx_menu.cljs | 6 +- .../ui/workspace/text_palette_ctx_menu.scss | 87 ++-- 6 files changed, 621 insertions(+), 127 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-many-typographies.json diff --git a/frontend/playwright/data/workspace/get-file-many-typographies.json b/frontend/playwright/data/workspace/get-file-many-typographies.json new file mode 100644 index 0000000000..1ec32392eb --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-many-typographies.json @@ -0,0 +1,465 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Many typographies", + "~:revn": 2, + "~:modified-at": "~m1717512948250", + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:is-shared": true, + "~:version": 48, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1717512934704", + "~:data": { + "~:pages": [ + "~uc1249a66-fce0-8175-8004-7433fe4be8bd" + ], + "~:pages-index": { + "~uc1249a66-fce0-8175-8004-7433fe4be8bd": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~uc70224ec-c410-807b-8004-743400e00be8" + ] + } + }, + "~uc70224ec-c410-807b-8004-743400e00be8": { + "~#shape": { + "~:y": 255, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 279.0000000000001, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 523, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 255 + } + }, + { + "~#point": { + "~:x": 802.0000000000001, + "~:y": 534 + } + }, + { + "~#point": { + "~:x": 523, + "~:y": 534 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~uc70224ec-c410-807b-8004-743400e00be8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 523, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 523, + "~:y": 255, + "~:width": 279.0000000000001, + "~:height": 279, + "~:x1": 523, + "~:y1": 255, + "~:x2": 802.0000000000001, + "~:y2": 534 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 0, + "~:height": 279, + "~:flip-y": null + } + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bd", + "~:name": "Page 1" + } + }, + "~:id": "~uc1249a66-fce0-8175-8004-7433fe4be8bc", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#187cd5", + "~:opacity": 1 + } + ], + "~:colors": { + "~uc70224ec-c410-807b-8004-74340616cffb": { + "~:path": "", + "~:color": "#187cd5", + "~:name": "test-color-187cd5", + "~:modified-at": "~m1717512945259", + "~:opacity": 1, + "~:id": "~uc70224ec-c410-807b-8004-74340616cffb" + } + }, + "~:typographies": { + "~ue117f7f6-433c-807e-8004-862ab6ae3000": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "12", + "~:font-weight": "400", + "~:name": "Type 01 Regular 12", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3000", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3001": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "12", + "~:font-weight": "700", + "~:name": "Type 02 Bold 12", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3001", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3002": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "400", + "~:name": "Type 03 Regular 14", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3002", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3003": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "700", + "~:name": "Type 04 Bold 14", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3003", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3004": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "400", + "~:name": "Type 05 Regular 16", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3004", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3005": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "16", + "~:font-weight": "700", + "~:name": "Type 06 Bold 16", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3005", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3006": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "18", + "~:font-weight": "400", + "~:name": "Type 07 Regular 18", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3006", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3007": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "18", + "~:font-weight": "700", + "~:name": "Type 08 Bold 18", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3007", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3008": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "20", + "~:font-weight": "400", + "~:name": "Type 09 Regular 20", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3008", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae3009": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "20", + "~:font-weight": "700", + "~:name": "Type 10 Bold 20", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae3009", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300a": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "22", + "~:font-weight": "400", + "~:name": "Type 11 Regular 22", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300a", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300b": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "22", + "~:font-weight": "700", + "~:name": "Type 12 Bold 22", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300b", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300c": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "24", + "~:font-weight": "400", + "~:name": "Type 13 Regular 24", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300c", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300d": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "24", + "~:font-weight": "700", + "~:name": "Type 14 Bold 24", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300d", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300e": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "26", + "~:font-weight": "400", + "~:name": "Type 15 Regular 26", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "regular", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300e", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + }, + "~ue117f7f6-433c-807e-8004-862ab6ae300f": { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:font-size": "26", + "~:font-weight": "700", + "~:name": "Type 16 Bold 26", + "~:modified-at": "~m1718718464655", + "~:font-variant-id": "700", + "~:id": "~ue117f7f6-433c-807e-8004-862ab6ae300f", + "~:letter-spacing": "0", + "~:font-family": "sourcesanspro" + } + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index db5a1002aa..807d1a45cd 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -772,6 +772,57 @@ test.describe("Nudge modal", () => { }); }); +test.describe("Text palette", () => { + test("Shows the scroll arrows and the palette menu", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-many-typographies.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + await workspace.palette + .getByRole("button", { name: "Typographies" }) + .click(); + await expect( + workspace.palette.getByText("Type 01 Regular 12"), + ).toBeVisible(); + await page.mouse.move(640, 300); + // The arrows are a small part of the palette: compare every pixel. + await expect(workspace.palette).toHaveScreenshot("text-palette.png", { + maxDiffPixelRatio: 0, + threshold: 0, + }); + + const rightArrow = workspace.palette.locator('[class*="right-arrow"]'); + await rightArrow.hover(); + await expect(workspace.palette).toHaveScreenshot( + "text-palette-arrow-hover.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + await rightArrow.click(); + await page.mouse.move(640, 300); + await expect(workspace.palette).toHaveScreenshot( + "text-palette-scrolled.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); + + await workspace.palette.locator('[class*="palette-actions"]').click(); + await expect(page.getByText("File library")).toBeVisible(); + await page.mouse.move(640, 300); + await expect(page).toHaveScreenshot("text-palette-menu.png", { + clip: { x: 300, y: 450, width: 700, height: 270 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Selected colors", () => { test("Shows the more colors button", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index b65d5842f7..e951b6120b 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -14,7 +14,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.object :as obj] @@ -144,7 +144,8 @@ (when show-arrows? [:button {:class (stl/css :left-arrow) :disabled (= offset 0) - :on-click on-left-arrow-click} deprecated-icon/arrow]) + :on-click on-left-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]]) [:div {:class (stl/css :text-palette-content) :ref container @@ -173,7 +174,8 @@ (when show-arrows? [:button {:class (stl/css :right-arrow) :disabled (= offset max-offset) - :on-click on-right-arrow-click} deprecated-icon/arrow])])) + :on-click on-right-arrow-click} + [:> icon* {:icon-id i/arrow :class (stl/css :arrow-icon)}]])])) (mf/defc text-palette* {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/workspace/text_palette.scss b/frontend/src/app/main/ui/workspace/text_palette.scss index 61cf22e34b..32dd942dc9 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.scss +++ b/frontend/src/app/main/ui/workspace/text_palette.scss @@ -4,54 +4,56 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .text-palette { - height: 100%; + block-size: 100%; display: flex; } +// Raw buttons on purpose: full-height scroll arrows with a fade, not DS +// button sizes. .left-arrow, .right-arrow { - @include deprecated.button-style; - @include deprecated.flex-center; + --arrow-foreground-color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; position: relative; - height: 100%; - width: deprecated.$s-24; + block-size: 100%; + inline-size: $sz-24; padding: 0; - z-index: deprecated.$z-index-2; + border: none; + background: none; + cursor: pointer; + color: var(--arrow-foreground-color); - svg { - @extend %button-icon; - } + // Above the typography list, which is positioned and comes after the + // left arrow, so the fade covers the items that scroll under it. + z-index: 2; &::after { content: ""; position: absolute; - bottom: 0; - left: calc(-1 * deprecated.$s-80); - height: 100%; - width: deprecated.$s-80; - z-index: deprecated.$z-index-1; - background-image: linear-gradient( - to left, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + inset-block-end: 0; + inset-inline-start: calc(-1 * px2rem(80)); + block-size: 100%; + inline-size: px2rem(80); + z-index: 1; + background-image: linear-gradient(to left, var(--color-background-primary) 0%, transparent 100%); pointer-events: none; } &:hover { - svg { - stroke: var(--button-foreground-hover); - } + --arrow-foreground-color: var(--color-accent-primary); } &:disabled { - svg { - stroke: var(--button-foreground-color-disabled); - } + --arrow-foreground-color: var(--color-foreground-disabled); &::after { background-image: none; @@ -61,19 +63,11 @@ .left-arrow { &::after { - left: deprecated.$s-24; - background-image: linear-gradient( - to right, - var(--palette-button-shadow-initial) 0%, - var(--palette-button-shadow-final) 100% - ); + inset-inline-start: $sz-24; + background-image: linear-gradient(to right, var(--color-background-primary) 0%, transparent 100%); } - &.disabled ::after { - background-image: none; - } - - svg { + .arrow-icon { transform: rotate(180deg); } } @@ -85,55 +79,46 @@ .text-palette-inside { display: flex; - gap: deprecated.$s-8; + gap: var(--sp-s); } .typography-item { - @include deprecated.body-small-typography; + --typography-item-background-color: var(--color-background-tertiary); + + @include use-typography("body-small"); display: flex; flex-direction: column; justify-content: center; align-items: flex-start; - height: 100%; - width: deprecated.$s-136; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - background-color: var(--palette-text-background-color); + block-size: 100%; + inline-size: px2rem(136); + padding: var(--sp-s); + border-radius: $br-8; + background-color: var(--typography-item-background-color); &:first-child { - margin-left: deprecated.$s-8; + margin-inline-start: var(--sp-s); + } + + .typography-name, + .typography-font, + .typography-data { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + block-size: $sz-16; + inline-size: $sz-120; + color: var(--color-foreground-secondary); } .typography-name { - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: deprecated.$s-120; - color: var(--palette-text-color-selected); - } - - .typography-font { - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: deprecated.$s-120; - color: var(--palette-text-color); - } - - .typography-data { - @include deprecated.text-ellipsis; - - height: deprecated.$s-16; - width: deprecated.$s-120; - color: var(--palette-text-color); + color: var(--color-foreground-primary); } &.mid-item { - .typography-name { - height: deprecated.$s-16; - } - .typography-data { display: none; } @@ -147,12 +132,12 @@ } &:hover { - background-color: var(--palette-text-background-color-hover); + --typography-item-background-color: var(--color-background-quaternary); } } .text-palette-empty { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--palette-text-color); + color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs index cdc13170f2..fd4ef95f3b 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs @@ -10,7 +10,7 @@ [app.common.data.macros :as dm] [app.main.refs :as refs] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -37,7 +37,7 @@ (when (= selected (:id cur-library)) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])])) + [:> icon* {:icon-id i/tick :size "s"}]])])) [:li {:class (stl/css-case :file-library true @@ -51,4 +51,4 @@ (dm/str "(" (count typographies) ")")]] (when (= selected :file) [:span {:class (stl/css :icon-wrapper)} - deprecated-icon/tick])]]])) + [:> icon* {:icon-id i/tick :size "s"}]])]]])) diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss index 9cf70b7e93..bc1ca05c25 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss @@ -4,86 +4,77 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .text-context-menu { position: absolute; - left: auto; - bottom: var(--height); - max-width: deprecated.$s-480; - padding: deprecated.$s-4; - margin: 0 0 deprecated.$s-4 0; - z-index: deprecated.$z-index-4; - border-radius: deprecated.$br-10; - background-color: var(--context-menu-background-color); + inset-inline-start: auto; + inset-block-end: var(--height); + max-inline-size: $sz-480; + padding: var(--sp-xs); + margin: 0 0 var(--sp-xs) 0; + + // Context menu level of the legacy scale, inside the palette. + z-index: 4; + border-radius: px2rem(10); + background-color: var(--color-background-tertiary); .palette-library, .file-library { + --library-foreground-color: var(--color-foreground-secondary); + position: relative; display: flex; justify-content: space-between; - width: 100%; - gap: deprecated.$s-8; - padding: deprecated.$s-8; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$br-8; + inline-size: 100%; + gap: var(--sp-s); + padding: var(--sp-s); + margin-block-end: var(--sp-xs); + border-radius: $br-8; &:last-child { - margin-bottom: 0; + margin-block-end: 0; } .library-name { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--context-menu-foreground-color); + color: var(--library-foreground-color); display: grid; - grid-template-columns: 1fr deprecated.$s-24; - max-width: deprecated.$s-400; + grid-template-columns: 1fr $sz-24; + max-inline-size: $sz-400; .lib-name { - @include deprecated.text-ellipsis; - - max-width: deprecated.$s-380; + display: block; + max-inline-size: px2rem(380); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .lib-num { - margin-left: deprecated.$s-4; + margin-inline-start: var(--sp-xs); } } .icon-wrapper { - margin-left: deprecated.$s-4; - - @include deprecated.flex-center; - - svg { - @include deprecated.flex-center; - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } + display: flex; + justify-content: center; + align-items: center; + margin-inline-start: var(--sp-xs); + color: var(--library-foreground-color); } &.selected, &:hover { - .icon-wrapper { - @include deprecated.flex-center; - - svg { - @include deprecated.flex-center; - @extend %button-icon-small; - - stroke: var(--context-menu-foreground-color-selected); - } - } - - .library-name { - color: var(--context-menu-foreground-color-selected); - } + --library-foreground-color: var(--color-foreground-primary); } &:hover { - background-color: var(--context-menu-background-color-selected); + background-color: var(--color-background-quaternary); } } } From ac08e38c1a61b108f98cb5086b009aafccd3c07a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:34 +0100 Subject: [PATCH 146/325] :recycle: Use DS buttons and icons in the export assets modal (#12517) * :recycle: Use DS buttons and icons in the export assets modal The export modal close button becomes a ghost DS icon button and the progress toast close button an action one, which fits its 24px grid column; both are labelled "Close". Cancel and Export become DS buttons. The checkbox ticks and the toast icons use DS icons. The selection rows stay raw buttons, since they are list rows, and so does the link-styled retry/cancel button of the toast. The export frames e2e spec now finds the Export button by role, and a visual test covers the modal with all, some and no rows selected. AI-assisted-by: claude-opus-5-5 * :recycle: Make the export progress ring a button The export progress ring in the header opens the export details, but it was a div with a click handler. It is now a button named after the export status, with aria-expanded, and keeps its look. AI-assisted-by: claude-opus-5-5 * :recycle: Move export assets modal styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the button placeholders go away. The classes left without use are removed: the old modal buttons, the old close icon, and the file export entries, which live in files.scss. The refactor/ import stays only for the shared modal and checkbox placeholders, which move to the DS with the modals and the checkboxes. The checkbox placeholder shrinks a ticked icon to 12px, which pushed the DS tick off center: the tick keeps its 16px box now, and does not shrink inside the 14px flex box either. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/export-frames.spec.js | 5 +- .../ui/visual-specs/workspace.spec.js | 55 +++ frontend/src/app/main/ui/exports/assets.cljs | 68 ++-- frontend/src/app/main/ui/exports/assets.scss | 366 ++++++++---------- 4 files changed, 246 insertions(+), 248 deletions(-) diff --git a/frontend/playwright/ui/specs/export-frames.spec.js b/frontend/playwright/ui/specs/export-frames.spec.js index fee390204d..aa34953a83 100644 --- a/frontend/playwright/ui/specs/export-frames.spec.js +++ b/frontend/playwright/ui/specs/export-frames.spec.js @@ -142,7 +142,10 @@ test.describe("Export frames to PDF", () => { await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); // The export button should still be enabled - const exportButton = page.locator("input[type='button'][value='Export']"); + const exportButton = page.getByRole("button", { + name: "Export", + exact: true, + }); await expect(exportButton).toBeEnabled(); }); diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 807d1a45cd..48e715b175 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -908,3 +908,58 @@ test.describe("Grid cell options", () => { }); }); }); + +test.describe("Export modal", () => { + test("Shows the export frames modal with every selection state", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-export-frames.json", + ); + await workspace.mockRPC( + "get-file-object-thumbnails?file-id=*", + "workspace/get-file-object-thumbnails-blank.json", + ); + await workspace.mockRPC( + "get-file-fragment?file-id=*", + "workspace/get-file-fragment-blank.json", + ); + await workspace.goToWorkspace({ + fileId: "8d102c33-b98f-81f6-8006-fdd4ea9780d7", + pageId: "8d102c33-b98f-81f6-8006-fdd4ea9780d8", + }); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-export-frames").click(); + const modal = page.getByText("Export as PDF").locator("../.."); + await expect(page.getByText("2 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Every row checked. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.getByRole("button", { name: "Board 1" }).click(); + await expect(page.getByText("1 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Mixed: the header checkbox shows the "some selected" mark. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await page.getByRole("button", { name: "Board 2" }).click(); + await expect(page.getByText("0 of 2 elements selected")).toBeVisible(); + await page.mouse.move(0, 0); + // Nothing checked: the export button is disabled. + await expect(modal).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index 83140cb47c..eb842c1f6b 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -16,7 +16,9 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.workspace.shapes :refer [shape-wrapper]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr c]] @@ -25,15 +27,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private neutral-icon - (deprecated-icon/icon-xref :msg-neutral (stl/css :icon))) - -(def ^:private error-icon - (deprecated-icon/icon-xref :delete-text (stl/css :icon))) - -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - (mf/defc export-multiple-dialog* {::mf/private true} [{:keys [exports title cmd no-selection origin name]}] @@ -83,9 +76,11 @@ [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click cancel-fn}]] [:* [:div {:class (stl/css :modal-content)} @@ -98,12 +93,12 @@ (cond all-checked? [:span {:class (stl/css-case :checkobox-tick true :global/checked true)} - deprecated-icon/tick] + [:> icon* {:icon-id i/tick :size "s"}]] all-unchecked? [:span {:class (stl/css-case :checkobox-tick true :global/uncheked true)}] :else [:span {:class (stl/css-case :checkobox-tick true :global/intermediate true)} - deprecated-icon/remove-icon])]] + [:> icon* {:icon-id i/remove :size "s"}]])]] [:div {:class (stl/css :selection-title)} (tr "dashboard.export-multiple.selected" (c (count enabled-exports)) @@ -122,7 +117,7 @@ (if (:enabled export) [:span {:class (stl/css-case :checkobox-tick true :global/checked true)} - deprecated-icon/tick] + [:> icon* {:icon-id i/tick :size "s"}]] [:span {:class (stl/css-case :checkobox-tick true :global/uncheked true)}])] @@ -158,19 +153,16 @@ (when (> (count all-exports) 0) [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click cancel-fn}] + [:> button* {:variant "secondary" + :on-click cancel-fn} + (tr "labels.cancel")] - [:input {:class (stl/css-case :accept-btn true - :btn-disabled (or in-progress? all-unchecked?)) - :disabled (or in-progress? all-unchecked?) - :type "button" - :value (if in-progress? - (tr "workspace.options.exporting-object") - (tr "labels.export")) - :on-click (when-not in-progress? accept-fn)}]]])]]])) + [:> button* {:variant "primary" + :disabled (or in-progress? all-unchecked?) + :on-click (when-not in-progress? accept-fn)} + (if in-progress? + (tr "workspace.options.exporting-object") + (tr "labels.export"))]]])]]])) (mf/defc shapes-no-selection [] [:div {:class (stl/css :no-selection)} @@ -291,8 +283,11 @@ [:* (when widget-visible? - [:div {:class (stl/css :export-progress-widget) - :on-click toggle-detail-visibility} + [:button {:type "button" + :class (stl/css :export-progress-widget) + :aria-label title + :aria-expanded (boolean detail-visible?) + :on-click toggle-detail-visibility} [:svg {:width "24" :height "24"} [:circle {:r "10" :cx "12" @@ -314,9 +309,8 @@ (when detail-visible? [:div {:class (stl/css-case :export-progress-modal true :has-error error?)} - (if error? - error-icon - neutral-icon) + [:> icon* {:icon-id (if error? i/delete-text i/msg-neutral) + :class (stl/css :icon)}] [:div {:class (stl/css :export-progress-title)} [:div {:class (stl/css :title-text)} title] @@ -342,9 +336,11 @@ [:span {:class (stl/css :progress)} (dm/str progress " / " total)])] - [:button {:class (stl/css :progress-close-button) - :on-click toggle-detail-visibility} - close-icon] + [:> icon-button* {:variant "action" + :tooltip-class (stl/css :progress-close-button) + :aria-label (tr "labels.close") + :icon i/close + :on-click toggle-detail-visibility}] (when-not error? [:svg {:class (stl/css :progress-bar) diff --git a/frontend/src/app/main/ui/exports/assets.scss b/frontend/src/app/main/ui/exports/assets.scss index 2780d53ea7..db3ec2d612 100644 --- a/frontend/src/app/main/ui/exports/assets.scss +++ b/frontend/src/app/main/ui/exports/assets.scss @@ -4,66 +4,75 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared modal and checkbox placeholders, which move to the DS +// with the modals and the checkboxes. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; // PROGRESS WIDGET .export-progress-widget { - @include deprecated.flex-center; - - width: deprecated.$s-28; - height: deprecated.$s-28; + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-28; + block-size: $sz-28; + padding: 0; + border: none; + background: none; + cursor: pointer; } // PROGRESS MODAL .export-progress-modal { - --export-modal-bg-color: var(--alert-background-color-default); - --export-modal-fg-color: var(--alert-text-foreground-color-default); - --export-modal-icon-color: var(--alert-icon-foreground-color-default); - --export-modal-border-color: var(--alert-border-color-default); + --export-modal-bg-color: var(--color-background-primary); + --export-modal-fg-color: var(--color-foreground-primary); + --export-modal-icon-color: var(--color-foreground-primary); + --export-modal-border-color: var(--color-background-quaternary); position: absolute; - right: deprecated.$s-16; - top: deprecated.$s-48; + inset-inline-end: var(--sp-l); + inset-block-start: $sz-48; display: grid; - grid-template-columns: deprecated.$s-24 1fr deprecated.$s-24; + grid-template-columns: $sz-24 1fr $sz-24; grid-template-areas: "icon text close" "bar bar bar"; - gap: deprecated.$s-4 deprecated.$s-8; - padding-block-start: deprecated.$s-8; + gap: var(--sp-xs) var(--sp-s); + padding-block-start: var(--sp-s); background-color: var(--export-modal-bg-color); - border: deprecated.$s-1 solid var(--export-modal-border-color); - border-radius: deprecated.$br-8; - z-index: deprecated.$z-index-modal; + border: $b-1 solid var(--export-modal-border-color); + border-radius: $br-8; + z-index: var(--z-index-set); overflow: hidden; } .has-error { - --export-modal-bg-color: var(--alert-background-color-error); - --export-modal-fg-color: var(--alert-text-foreground-color-error); - --export-modal-icon-color: var(--alert-icon-foreground-color-error); - --export-modal-border-color: var(--alert-border-color-error); + --export-modal-bg-color: var(--color-background-error); + --export-modal-icon-color: var(--color-accent-error); + --export-modal-border-color: var(--color-accent-error); grid-template-areas: "icon text close"; - gap: deprecated.$s-8; - padding-block: deprecated.$s-8; + gap: var(--sp-s); + padding-block: var(--sp-s); } .icon { - @extend %button-icon; - grid-area: icon; align-self: center; - margin-inline-start: deprecated.$s-8; - stroke: var(--export-modal-icon-color); + margin-inline-start: var(--sp-s); + color: var(--export-modal-icon-color); } .export-progress-title { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); display: grid; grid-template-columns: auto 1fr; - gap: deprecated.$s-8; + gap: var(--sp-s); grid-area: text; align-self: center; padding: 0; @@ -72,130 +81,137 @@ } .progress { - @include deprecated.body-medium-typography; + @include use-typography("body-medium"); - padding-left: deprecated.$s-8; + padding-inline-start: var(--sp-s); margin: 0; align-self: center; - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } +// Link-styled text button: stays a raw button. .retry-btn { - @include deprecated.button-style; - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: inline; - text-align: left; - color: var(--modal-link-foreground-color); + text-align: start; + color: var(--color-accent-primary); margin: 0; padding: 0; + border: none; + background: none; + cursor: pointer; } .progress-close-button { - @include deprecated.button-style; - - padding: 0; - margin-inline-end: deprecated.$s-8; -} - -.close-icon { - @extend %button-icon; - - stroke: var(--export-modal-icon-color); + margin-inline-end: var(--sp-s); } .progress-bar { - margin-top: 0; + margin-block-start: 0; grid-area: bar; } // EXPORT MODAL .modal-overlay { @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } } .modal-container { @extend %modal-container-base; - max-height: calc(10 * deprecated.$s-80); + max-block-size: px2rem(800); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.headline-medium-typography; + @include use-typography("headline-medium"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content, .no-selection { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); .modal-link { - @include deprecated.body-large-typography; + @include use-typography("body-large"); text-decoration: none; cursor: pointer; - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } .selection-header { - @include deprecated.flex-row; - - height: deprecated.$s-32; - margin-bottom: deprecated.$s-4; + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: $sz-32; + margin-block-end: var(--sp-xs); .selection-btn { - @include deprecated.button-style; @extend %input-checkbox; - @include deprecated.flex-center; - height: deprecated.$s-24; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; padding: 0; - margin-left: deprecated.$s-16; + margin-inline-start: var(--sp-l); + border: none; + background: none; + cursor: pointer; span { @extend %checkbox-icon; + + // The DS icon draws its 12px glyph centered in a 16px box: keep that + // box, unshrunk, instead of the 12px the placeholder gives a legacy + // icon. + &:global(.checked) svg, + &:global(.intermediate) svg { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + } } } .selection-title { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } } .selection-wrapper { position: relative; - width: 100%; - height: fit-content; + inline-size: 100%; + block-size: fit-content; } .selection-shadow { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; &::after { position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 50px; + inset-block-end: 0; + inset-inline-start: 0; + inline-size: 100%; + block-size: px2rem(50); background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); content: ""; pointer-events: none; @@ -203,84 +219,99 @@ } .selection-list { - @include deprecated.flex-column; - - max-height: deprecated.$s-400; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + max-block-size: $sz-400; overflow-y: auto; - padding-bottom: deprecated.$s-12; + padding-block-end: var(--sp-m); .selection-row { - @include deprecated.flex-row; - - background-color: var(--entry-background-color); - min-height: deprecated.$s-40; - border-radius: deprecated.$br-8; + display: flex; + align-items: center; + gap: var(--sp-xs); + background-color: var(--color-background-tertiary); + min-block-size: $sz-40; + border-radius: $br-8; + // A list row, not a DS button: the whole row toggles its export. .selection-btn { - @include deprecated.button-style; - display: grid; grid-template-columns: min-content auto 1fr auto auto; align-items: center; - width: 100%; - height: 10%; - gap: deprecated.$s-8; - padding: 0 deprecated.$s-16; + inline-size: 100%; + block-size: 10%; + gap: var(--sp-s); + padding: 0 var(--sp-l); + border: none; + background: none; + cursor: pointer; .checkbox-wrapper { @extend %input-checkbox; - @include deprecated.flex-center; - height: deprecated.$s-24; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; padding: 0; .checkobox-tick { @extend %checkbox-icon; + + // The DS icon draws its 12px glyph centered in a 16px box: keep that + // box, unshrunk, instead of the 12px the placeholder gives a + // legacy icon. + &:global(.checked) svg, + &:global(.intermediate) svg { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + } } } - .selection-name { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; + .selection-name, + .selection-scale, + .selection-extension { + @include use-typography("body-large"); + @include text-ellipsis; + color: var(--color-foreground-secondary); + } + + .selection-name { flex-grow: 1; - color: var(--modal-text-foreground-color); text-align: start; } .selection-scale { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-108; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); + min-inline-size: px2rem(108); + padding: var(--sp-m); } .selection-extension { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-72; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); + min-inline-size: px2rem(72); + padding: var(--sp-m); } } .image-wrapper { - @include deprecated.flex-center; - - min-height: deprecated.$s-32; - min-width: deprecated.$s-32; - background-color: var(--app-white); - border-radius: deprecated.$br-6; + display: flex; + justify-content: center; + align-items: center; + min-block-size: $sz-32; + min-inline-size: $sz-32; + background-color: var(--color-static-white); + border-radius: $br-6; margin: auto 0; + // The thumbnail markup has no class of its own. img, svg { object-fit: contain; - max-height: deprecated.$s-40; + max-block-size: $sz-40; } } } @@ -288,101 +319,14 @@ } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } + display: flex; + justify-content: flex-end; + gap: var(--sp-l); } .modal-scd-msg, -.modal-subtitle, .modal-msg { - @include deprecated.body-large-typography; + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); -} - -.export-option { - @extend %input-checkbox; - - width: 100%; - align-items: flex-start; - - label { - align-items: flex-start; - - .modal-subtitle { - @include deprecated.body-large-typography; - - color: var(--modal-title-foreground-color); - } - } - - span { - margin-top: deprecated.$s-8; - } -} - -.option-content { - @include deprecated.flex-column; - @include deprecated.body-large-typography; -} - -.file-entry { - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--input-foreground); - } - } - - .file-name-label { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - } - } - - &.loading { - .file-name { - color: var(--modal-text-foreground-color); - } - } - - &.error { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - } - } - - &.success { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - } - } + color: var(--color-foreground-secondary); } From 7ff8df6ffb583b62d36e687214ab5b3228d3a997 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:31:51 +0100 Subject: [PATCH 147/325] :recycle: Name the grid track expand buttons and make the annotation toggle a button (#12489) * :recycle: Name the grid track expand buttons The menu button that expands the columns or rows of a grid layout had no accessible name. Label it "Columns" or "Rows" and expose its state with aria-expanded. The track title next to it also toggles on click; it stays a div, as a larger pointer target for the same action, so the keyboard does not stop twice on it. Add the workspace.layout-grid.editor.columns and rows keys (en, es). AI-assisted-by: claude-opus-5-5 * :recycle: Make the annotation title toggle a button The annotation title of a component toggled from a div with a click handler, and it holds the edit, delete, save and discard buttons, so it cannot become a single button. Move the toggle to a button with the arrow and the "Annotation" name, with aria-expanded, that takes the whole free width of the title. While editing or creating, the title stays text and no longer collapses the annotation on click, as its cursor already suggested. Extend the annotation visual test with the collapsed state; it looks the same as before, pixel for pixel. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 10 ++++++++++ .../sidebar/options/menus/component.cljs | 11 ++++++---- .../sidebar/options/menus/component.scss | 20 +++++++++++++++---- .../options/menus/layout_container.cljs | 9 ++++++++- frontend/translations/en.po | 8 ++++++++ frontend/translations/es.po | 8 ++++++++ 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 48e715b175..763fc4cfb1 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -374,6 +374,16 @@ test.describe("Component options", () => { "annotation-saved.png", { maxDiffPixelRatio: 0, threshold: 0 }, ); + + // Collapsed by clicking its title. + await title.click(); + await expect( + workspace.rightSidebar.getByText("An annotation"), + ).toBeHidden(); + await expect(workspace.rightSidebar).toHaveScreenshot( + "annotation-collapsed.png", + { maxDiffPixelRatio: 0, threshold: 0 }, + ); }); }); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 00660a3dfb..9f30b24036 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -175,9 +175,7 @@ :editing editing? :creating creating?)} [:div {:class (stl/css-case :annotation-title true - :expandeable (not (or editing? creating?)) - :expanded expanded?) - :on-click on-toggle-expand} + :expanded expanded?)} (if (or editing? creating?) [:span {:class (stl/css :annotation-title-name)} @@ -185,7 +183,12 @@ (tr "workspace.options.component.edit-annotation") (tr "workspace.options.component.create-annotation"))] - [:* + ;; Only the arrow and the name toggle: the actions next to them + ;; are buttons too, and a button cannot hold other buttons. + [:button {:type "button" + :class (stl/css :annotation-title-toggle) + :aria-expanded (boolean expanded?) + :on-click on-toggle-expand} [:> icon* {:icon-id (if expanded? i/arrow-down i/arrow-right) :class (stl/css :annotation-title-icon-arrow) :size "s"}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index acbe025003..77403735b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -73,10 +73,6 @@ border-bottom: $b-1 solid var(--color-background-quaternary); } - &.expandeable { - cursor: pointer; - } - &:hover { .annotation-title-icon-action { display: flex; @@ -84,6 +80,22 @@ } } +// Takes the whole free width of the title, so the clickable area stays +// as it was when the whole title toggled. +.annotation-title-toggle { + display: flex; + flex-grow: 1; + align-items: center; + align-self: stretch; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} + .annotation-title-name { flex-grow: 1; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index f8dcee251d..53b53d711c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -1037,7 +1037,14 @@ [:div {:class (stl/css :grid-tracks) :data-testid testid} [:div {:class (stl/css :grid-track-header)} - [:button {:class (stl/css :expand-icon) :on-click toggle} + ;; The track title next to it toggles too, as a larger pointer + ;; target; this button is the one for the keyboard. + [:button {:class (stl/css :expand-icon) + :aria-label (if is-column + (tr "workspace.layout-grid.editor.columns") + (tr "workspace.layout-grid.editor.rows")) + :aria-expanded (boolean expanded?) + :on-click toggle} [:> icon* {:icon-id i/menu :class (stl/css :expand-icon-svg)}]] [:div {:class (stl/css :track-title) :on-click toggle} [:div {:class (stl/css :track-name) :title track-name} track-name] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fe90880cc5..e971e9decc 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6747,6 +6747,14 @@ msgstr "Full screen" msgid "workspace.header.zoom-selected" msgstr "Zoom to selected" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.columns" +msgstr "Columns" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.rows" +msgstr "Rows" + #: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:296, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:854 msgid "workspace.layout-grid.editor.options.edit-grid" msgstr "Edit grid" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2d2b8109bd..65ab208de5 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6831,6 +6831,14 @@ msgstr "Pantalla completa" msgid "workspace.header.zoom-selected" msgstr "Zoom a selección" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.columns" +msgstr "Columnas" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.rows" +msgstr "Filas" + #: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:296, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:854 msgid "workspace.layout-grid.editor.options.edit-grid" msgstr "Editar grid" From 9911f82087079527e36e5b29c77e747f4fe7d5f0 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:32:06 +0100 Subject: [PATCH 148/325] :recycle: Use a DS icon in the grid editor track options button (#12486) * :recycle: Use a DS icon in the grid editor track options button The options button of each grid track on the canvas drew a deprecated icon and had no accessible name. Use the DS menu icon and label the button "Track options". The button stays raw: it lives on the canvas, where the track editor keeps a fixed screen size at any zoom. The DS icon has a fixed 16px size, so its class scales it down with the zoom (transform: scale(1 / zoom)), as the old svg rule did with its width. It looks the same, but for a few antialiasing pixels. Add the workspace.layout-grid.editor.track-options key (en, es) and a visual test of a track at two zoom levels. AI-assisted-by: claude-opus-5-5 * :recycle: Move grid editor styles off the deprecated styles Move grid_layout_editor.scss to DS sizes, borders, spacing tokens, colors and logical properties, and drop the refactor import. The canvas sizes keep their division by the zoom. The grid editor color aliases resolve to their DS tokens, and the unused close-btn class, the last user of the deprecated button placeholders here, goes away. The track editor and the editing bar look the same as before. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 57 ++++++++++ .../viewport/grid_layout_editor.cljs | 12 +- .../viewport/grid_layout_editor.scss | 106 +++++++++--------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 128 insertions(+), 55 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 763fc4cfb1..2423ac72e0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -136,6 +136,63 @@ test.describe("Grid layout", () => { }); }); +test.describe("Grid editor", () => { + test("Shows the track options button on the canvas", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9042.json"); + await workspace.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-fragment-9042.json", + ); + await workspace.goToWorkspace({ + fileId: "af2494d0-39ba-8184-8005-230696f6df5c", + pageId: "af2494d0-39ba-8184-8005-230696f6df5d", + }); + + await workspace.clickLeafLayer("Board"); + // Tracks need 110px or more on screen to show their options button. + await page.keyboard.press("Control+="); + await page.keyboard.press("Control+="); + await page + .getByTestId("inspect-layout") + .getByRole("button", { name: "Edit grid" }) + .click(); + + const shotTrack = async () => { + const track = page.getByTestId("grid-track-editor-wrapper").first(); + await track.hover(); + await expect( + page.getByTestId("grid-track-options-btn").first(), + ).toBeVisible(); + // Wait until the zoom animation leaves the track in place. + let box = await track.boundingBox(); + await expect + .poll(async () => { + const prev = box; + box = await track.boundingBox(); + return prev.x === box.x && prev.width === box.width; + }) + .toBe(true); + await expect(page).toHaveScreenshot({ + clip: { + x: Math.floor(box.x) - 8, + y: Math.floor(box.y) - 8, + width: Math.ceil(box.width) + 16, + height: Math.ceil(box.height) + 16, + }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + }; + + // The track editors draw at a fixed screen size at any zoom. + await shotTrack(); + await page.keyboard.press("Control+="); + await shotTrack(); + }); +}); + test.describe("Design tab", () => { test("Shows the boolean options when selecting two shapes", async ({ page, diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs index d8e1314dc4..2f92b9e19e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs @@ -29,8 +29,8 @@ [app.main.store :as st] [app.main.ui.css-cursors :as cur] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.formats :as fmt] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.util.debug :as dbg] [app.util.dom :as dom] @@ -918,9 +918,15 @@ :on-key-down handle-keydown-track-input :on-blur handle-blur-track-input}] (when (and (not medium?) (not small?)) - [:button {:class (stl/css :grid-editor-button) + ;; Raw button on purpose: it lives on the canvas and keeps a + ;; fixed screen size at any zoom. + [:button {:type "button" + :class (stl/css :grid-editor-button) :data-testid "grid-track-options-btn" - :on-click handle-show-track-menu} deprecated-icon/menu])]])] + :aria-label (tr "workspace.layout-grid.editor.track-options") + :on-click handle-show-track-menu} + [:> icon* {:icon-id i/menu + :class (stl/css :grid-editor-button-icon)}]])]])] [:g {:transform (when (= type :row) (dm/fmt "rotate(-90 % %)" (:x marker-p) (:y marker-p)))} [:& track-marker diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss index 008f4e8b0d..6215876b48 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss @@ -4,17 +4,23 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/colors.scss" as *; @use "ds/z-index.scss" as *; +// The track editor lives on the canvas: its sizes are divided by the zoom +// so it keeps the same size on screen. + .grid-track-marker { .marker-shape { - fill: var(--grid-editor-marker-color); + fill: var(--color-accent-quaternary); } .marker-text { - fill: var(--app-white); - font-size: calc(deprecated.$s-12 / var(--zoom)); + fill: $white; + font-size: calc(#{px2rem(12)} / var(--zoom)); font-family: "worksans", "vazirmatn", sans-serif; } } @@ -22,8 +28,8 @@ .grid-editor-wrapper { cursor: grab; position: relative; - width: 100%; - height: 80%; + inline-size: 100%; + block-size: 80%; display: flex; flex-direction: column; justify-content: center; @@ -31,21 +37,20 @@ } .grid-editor-header-hover { - fill: var(--grid-editor-marker-color); + fill: var(--color-accent-quaternary); } .grid-editor-label { flex: 1; background: none; border: 0; - color: var(--grid-editor-marker-text); + color: var(--color-accent-quaternary); font-family: "worksans", "vazirmatn", sans-serif; - font-size: calc(deprecated.$fs-12 / var(--zoom)); + font-size: calc(#{px2rem(12)} / var(--zoom)); font-weight: 400; margin: 0; - max-width: calc(deprecated.$s-60 / var(--zoom)); - padding: 0; - padding: calc(deprecated.$s-4 / var(--zoom)); + max-inline-size: calc(#{px2rem(60)} / var(--zoom)); + padding: calc(var(--sp-xs) / var(--zoom)); text-align: center; &:focus { @@ -60,21 +65,23 @@ margin: 0; padding: 0; position: absolute; - top: calc(deprecated.$s-6 / var(--zoom)); - right: calc(deprecated.$s-20 / var(--zoom)); - width: calc(deprecated.$s-20 / var(--zoom)); - height: calc(deprecated.$s-20 / var(--zoom)); + inset-block-start: calc(#{px2rem(6)} / var(--zoom)); + inset-inline-end: calc(#{px2rem(20)} / var(--zoom)); + inline-size: calc(#{px2rem(20)} / var(--zoom)); + block-size: calc(#{px2rem(20)} / var(--zoom)); opacity: 0; pointer-events: none; +} - svg { - position: absolute; - top: 0; - left: 0; - width: calc(deprecated.$s-16 / var(--zoom)); - height: auto; - stroke: var(--grid-editor-marker-color); - } +// The DS icon has a fixed 16px size: scale it down with the zoom, as the +// rest of the track editor, so it keeps the same size on screen. +.grid-editor-button-icon { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + transform: scale(calc(1 / var(--zoom))); + transform-origin: top left; + color: var(--color-accent-quaternary); } .grid-editor-wrapper:hover .grid-editor-button { @@ -83,7 +90,7 @@ } .grid-frame { - fill: var(--grid-editor-marker-color); + fill: var(--color-accent-quaternary); fill-opacity: 0.1; } @@ -92,13 +99,13 @@ opacity: 0.5; .grid-plus-shape { - fill: var(--grid-editor-plus-btn-background); - stroke: var(--grid-editor-plus-btn-background); - stroke-width: calc(deprecated.$s-1 / var(--zoom)); + fill: var(--color-accent-quaternary); + stroke: var(--color-accent-quaternary); + stroke-width: calc(#{px2rem(1)} / var(--zoom)); } .grid-plus-icon { - stroke: var(--grid-editor-plus-btn-foreground); + stroke: $white; } &:hover { @@ -108,7 +115,7 @@ .grid-cell-outline { fill: transparent; - stroke: var(--grid-editor-line-color); + stroke: var(--color-accent-quaternary); stroke-opacity: 0.5; stroke-width: calc(1 / var(--zoom)); @@ -122,37 +129,32 @@ .grid-actions { pointer-events: none; position: absolute; - top: deprecated.$s-44; - left: 50%; + inset-block-start: px2rem(44); + inset-inline-start: 50%; z-index: var(--z-index-notifications); } .grid-actions-container { - @include deprecated.flex-row; - - background: var(--panel-background-color); - border-radius: deprecated.$br-12; - box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); - gap: deprecated.$s-8; - height: deprecated.$s-48; - margin-left: -50%; - padding: deprecated.$s-8; + display: flex; + align-items: center; + gap: var(--sp-s); + background: var(--color-background-primary); + border-radius: $br-12; + box-shadow: 0 0 px2rem(12) 0 var(--color-shadow-dark); + block-size: $sz-48; + margin-inline-start: -50%; + padding: var(--sp-s); cursor: initial; pointer-events: initial; - width: deprecated.$s-512; + inline-size: $sz-512; } .grid-actions-title { flex: 1; - font-size: deprecated.$fs-12; + + // TODO: No exact DS typography match found. + // Closest match: body-small. + font-size: px2rem(12); color: var(--color-foreground-secondary); - padding-left: deprecated.$s-8; -} - -.close-btn { - @extend %button-tertiary; - - svg { - @extend %button-icon; - } + padding-inline-start: var(--sp-s); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e971e9decc..4d87a2c487 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6791,6 +6791,10 @@ msgstr "Top padding" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Vertical padding" +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +msgid "workspace.layout-grid.editor.track-options" +msgstr "Track options" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Editing grid" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 65ab208de5..d40ca22260 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6875,6 +6875,10 @@ msgstr "Padding superior" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Padding vertical" +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +msgid "workspace.layout-grid.editor.track-options" +msgstr "Opciones de la pista" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Editando rejilla" From ef17aea39eb0c56e66a506470ddc40e8a08efe9f Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 10:33:11 +0100 Subject: [PATCH 149/325] :recycle: Use DS icon buttons in the viewer thumbnails panel (#12518) * :recycle: Use DS icon buttons in the viewer thumbnails panel The expand, close and scroll buttons of the viewer thumbnails panel become ghost DS icon buttons. They had no accessible name; they are now labelled, and the expand button says whether the panel is expanded. The rotated legacy arrow gives way to the DS arrow icons. The thumbnails stay raw buttons, since they are list items. New keys: labels.expand and viewer.thumbnails.scroll-left/right (en, es). A visual test covers the panel collapsed, on hover and expanded. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer thumbnails panel styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the file no longer imports refactor/. The scroll buttons keep their 60px height through --button-height and still show only on hover; their tooltip wrappers take the grid cells. The DS body-small typography differs from the old one only in a more compact line-height, so the counter and the board names sit 1px higher. The panel z-index (10) has no DS token and keeps a TODO. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 39 ++++++ .../src/app/main/ui/viewer/thumbnails.cljs | 37 +++-- .../src/app/main/ui/viewer/thumbnails.scss | 127 ++++++++---------- frontend/translations/en.po | 12 ++ frontend/translations/es.po | 12 ++ 5 files changed, 145 insertions(+), 82 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 8361c263fb..9fb96ad4b3 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -151,3 +151,42 @@ test("User opens Share modal", async ({ page }) => { ).toBeVisible(); await expect(viewerPage.page).toHaveScreenshot(); }); + +test.describe("Thumbnails panel", () => { + test("Shows the thumbnails panel collapsed and expanded", async ({ + page, + }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithMultipleBoards(); + await viewerPage.goToViewer({ + fileId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0", + pageId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3", + }); + + await page.locator("#current-frame").click(); + const panel = page.locator("section").filter({ hasText: /boards/i }); + await expect(panel).toBeVisible(); + await page.mouse.move(640, 600); + // Collapsed: the summary with its expand and close buttons. + await expect(panel).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + // The scroll handlers only show on hover. + await panel.locator("button").nth(3).hover(); + await expect(panel).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await panel.locator("button").first().click(); + await page.mouse.move(640, 700); + // Expanded: the expand arrow points up. + await expect(panel).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index d7af5b5d41..d23c5009b8 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -14,7 +14,8 @@ [app.main.data.viewer :as dv] [app.main.render :as render] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.object :as obj] @@ -63,10 +64,18 @@ [:div {:class (stl/css :thumbnails-list-expanded)} children]] [:div {:class (stl/css :thumbnails-content)} - [:button {:class (stl/css :left-scroll-handler) - :on-click on-left-arrow-click} deprecated-icon/arrow] - [:button {:class (stl/css :right-scroll-handler) - :on-click on-right-arrow-click} deprecated-icon/arrow] + [:> icon-button* {:variant "ghost" + :class (stl/css :scroll-handler) + :tooltip-class (stl/css :left-scroll-handler) + :aria-label (tr "viewer.thumbnails.scroll-left") + :icon i/arrow-left + :on-click on-left-arrow-click}] + [:> icon-button* {:variant "ghost" + :class (stl/css :scroll-handler) + :tooltip-class (stl/css :right-scroll-handler) + :aria-label (tr "viewer.thumbnails.scroll-right") + :icon i/arrow-right + :on-click on-right-arrow-click}] [:div {:class (stl/css :thumbnails-list) :ref container @@ -76,15 +85,22 @@ children]]]))) (mf/defc thumbnails-summary* - [{:keys [on-toggle-expand on-close total]}] + [{:keys [on-toggle-expand on-close total is-expanded]}] [:div {:class (stl/css :thumbnails-summary)} [:span {:class (stl/css :counter)} (tr "labels.num-of-frames" (i18n/c total))] [:span {:class (stl/css :actions)} - [:button {:class (stl/css :expand-btn) - :on-click on-toggle-expand} deprecated-icon/arrow] - [:button {:class (stl/css :close-btn) - :on-click on-close} deprecated-icon/close]]]) + [:> icon-button* {:variant "ghost" + :aria-label (if is-expanded + (tr "labels.collapse") + (tr "labels.expand")) + :aria-expanded (boolean is-expanded) + :icon (if is-expanded i/arrow-up i/arrow-down) + :on-click on-toggle-expand}] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close}]]]) (mf/defc thumbnail-item* {::mf/wrap [mf/memo @@ -137,6 +153,7 @@ [:> thumbnails-summary* {:on-toggle-expand toggle-expand :on-close on-close + :is-expanded expanded? :total (count frames)}] [:> thumbnails-content* {:is-expanded expanded? :total (count frames)} diff --git a/frontend/src/app/main/ui/viewer/thumbnails.scss b/frontend/src/app/main/ui/viewer/thumbnails.scss index 63dc586a67..fdea0a3443 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.scss +++ b/frontend/src/app/main/ui/viewer/thumbnails.scss @@ -4,65 +4,52 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; .viewer-thumbnails { - background-color: var(--viewer-background-color); + background-color: var(--color-background-secondary); grid-row: 1 / span 1; grid-column: 1 / span 1; overflow: hidden; display: flex; flex-direction: column; - z-index: deprecated.$z-index-10; + + // TODO: no DS z-index token for 10; it keeps the panel above the + // viewer content (z-index 2). + z-index: 10; } .expanded { grid-row: 1 / span 2; - - .expand-btn svg { - transform: rotate(-90deg); - } } .thumbnails-summary { display: flex; justify-content: space-between; align-items: center; - height: deprecated.$s-32; - margin: deprecated.$s-24 deprecated.$s-24 0 deprecated.$s-24; + block-size: $sz-32; + margin: var(--sp-xxl) var(--sp-xxl) 0 var(--sp-xxl); } .counter { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--viewer-thumbnails-control-foreground-color); + color: var(--color-foreground-secondary); } .actions { - @include deprecated.flex-row; - - width: deprecated.$s-60; -} - -.expand-btn, -.close-btn { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } -} - -.expand-btn svg { - transform: rotate(90deg); + display: flex; + align-items: center; + gap: var(--sp-xs); } .thumbnails-content { display: grid; - grid-template-columns: deprecated.$s-40 auto deprecated.$s-40; + grid-template-columns: $sz-40 auto $sz-40; grid-template-rows: auto; } @@ -74,36 +61,28 @@ overflow: hidden; } +// Tooltip wrappers of the scroll buttons: they take the grid cell. .right-scroll-handler, .left-scroll-handler { - @extend %button-tertiary; - @include deprecated.flex-center; - grid-column: 3 / span 1; grid-row: 1 / span 1; - width: deprecated.$s-32; - height: deprecated.$s-60; margin: auto 0; - z-index: deprecated.$z-index-10; - opacity: 0; - - &:hover { - opacity: 1; - } - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + z-index: 10; } .left-scroll-handler { grid-column: 1 / span 1; - grid-row: 1 / span 1; +} - svg { - transform: rotate(180deg); +// The scroll buttons are as tall as a thumbnail row and only show on hover. +.thumbnails-content .scroll-handler { + --button-height: #{px2rem(60)}; + + opacity: 0; + + &:hover, + &:focus-visible { + opacity: 1; } } @@ -120,45 +99,49 @@ position: relative; } +// A list item, not a DS button. .thumbnail-item { - @include deprecated.button-style; - display: flex; flex-direction: column; - padding: deprecated.$s-16; + padding: var(--sp-l); + border: none; + background: none; + cursor: pointer; } .thumbnail-preview { - @include deprecated.flex-center; - - width: deprecated.$s-132; - min-height: deprecated.$s-132; - height: deprecated.$s-132; - padding: deprecated.$s-4; + display: flex; + justify-content: center; + align-items: center; + inline-size: px2rem(132); + min-block-size: px2rem(132); + block-size: px2rem(132); + padding: var(--sp-xs); + // The board preview markup has no class of its own. svg { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; } &.selected { - background-color: var(--viewer-thumbnail-background-color-selected); - border-radius: deprecated.$br-8; + background-color: var(--color-accent-primary-muted); + border-radius: $br-8; } &:hover { - border: deprecated.$s-1 solid var(--viewer-thumbnail-border-color); - border-radius: deprecated.$br-8; + border: $b-1 solid var(--color-accent-primary); + border-radius: $br-8; } } .thumbnail-info { - @include deprecated.body-small-typography; - @include deprecated.text-ellipsis; + @include use-typography("body-small"); + @include text-ellipsis; text-align: center; - color: var(--viewer-thumbnails-control-foreground-color); - padding: deprecated.$s-8 0; - width: 100%; - max-width: deprecated.$s-132; + color: var(--color-foreground-secondary); + padding: var(--sp-s) 0; + inline-size: 100%; + max-inline-size: px2rem(132); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 4d87a2c487..577cc740a1 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2948,6 +2948,10 @@ msgstr "Error" msgid "labels.exit" msgstr "Exit" +#: src/app/main/ui/viewer/thumbnails.cljs:96 +msgid "labels.expand" +msgstr "Expand" + #: src/app/main/ui/dashboard/team.cljs:782 msgid "labels.expired-invitation" msgstr "Expired" @@ -6127,6 +6131,14 @@ msgstr "Show interactions on click" msgid "viewer.header.sitemap" msgstr "Sitemap" +#: src/app/main/ui/viewer/thumbnails.cljs:70 +msgid "viewer.thumbnails.scroll-left" +msgstr "Scroll left" + +#: src/app/main/ui/viewer/thumbnails.cljs:76 +msgid "viewer.thumbnails.scroll-right" +msgstr "Scroll right" + #: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:76 msgid "webgl.modals.webgl-unavailable.cta-dashboard" msgstr "Go to dashboard" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index d40ca22260..434c88d4ba 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2986,6 +2986,10 @@ msgstr "Error" msgid "labels.exit" msgstr "Salir" +#: src/app/main/ui/viewer/thumbnails.cljs:96 +msgid "labels.expand" +msgstr "Expandir" + #: src/app/main/ui/dashboard/team.cljs:782 msgid "labels.expired-invitation" msgstr "Expirada" @@ -6208,6 +6212,14 @@ msgstr "Mostrar interacciones al hacer click" msgid "viewer.header.sitemap" msgstr "Mapa del sitio" +#: src/app/main/ui/viewer/thumbnails.cljs:70 +msgid "viewer.thumbnails.scroll-left" +msgstr "Desplazar a la izquierda" + +#: src/app/main/ui/viewer/thumbnails.cljs:76 +msgid "viewer.thumbnails.scroll-right" +msgstr "Desplazar a la derecha" + #: src/app/main/ui/workspace/webgl_unavailable_modal.cljs:76 msgid "webgl.modals.webgl-unavailable.cta-dashboard" msgstr "Ir al panel" From bac62a27bf23bbd047e98d320dc467e45d7391f6 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 11:36:46 +0100 Subject: [PATCH 150/325] :recycle: Use DS icons in the viewer comments filter (#12519) * :recycle: Use DS icons in the viewer comments filter The "Comments" filter trigger in the viewer header was a div with a click handler, and its menu lived inside it. It is now a button with aria-expanded and aria-haspopup, and the menu sits next to it in a wrapper, since a button cannot hold the menu entries. The arrow and the ticks use DS icons. The menu entries stay as they are. A visual test covers the filter closed and open. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer comments styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties. The DS icons take their color from the text and no longer shrink, so the old icon placeholders go away. The DS body-small line-height is more compact, so "Comments" sits 1px higher. The refactor/ import stays only for the shared menu placeholders. The z-index values (1 and 10) have no DS token and keep a TODO. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 32 +++++ frontend/src/app/main/ui/viewer/comments.cljs | 31 ++--- frontend/src/app/main/ui/viewer/comments.scss | 115 ++++++++---------- 3 files changed, 99 insertions(+), 79 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 9fb96ad4b3..95b3e58edc 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -190,3 +190,35 @@ test.describe("Thumbnails panel", () => { }); }); }); + +test.describe("Comments filter", () => { + test("Shows the comments filter closed and open", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + await viewerPage.showComments(); + + const trigger = page.getByTestId("viewer-comments-dropdown"); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + // Closed: the "Comments" trigger with its arrow. + await expect(trigger).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await trigger.click(); + await expect(page.getByText("Show comments list")).toBeVisible(); + await page.mouse.move(640, 600); + // Open: the filter menu with the selected option ticked. + const menu = page.getByText("Show comments list").locator("../.."); + await expect(menu).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index aac174d880..9e56e7f4e8 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -20,7 +20,7 @@ [app.main.store :as st] [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.workspace.comments :as wc] [app.main.ui.workspace.viewport.utils :as utils] [app.util.dom :as dom] @@ -66,11 +66,17 @@ (parse-boolean))] (st/emit! (dcm/update-options {:show-sidebar? (not mode)})))))] - [:div {:class (stl/css :view-options) - :data-testid "viewer-comments-dropdown" - :on-click toggle-dropdown} - [:span {:class (stl/css :dropdown-title)} (tr "labels.comments")] - [:span {:class (stl/css :icon-dropdown)} deprecated-icon/arrow] + [:div {:class (stl/css :view-options-wrapper)} + [:button {:type "button" + :class (stl/css :view-options) + :data-testid "viewer-comments-dropdown" + :aria-expanded (deref show-dropdown?) + :aria-haspopup "menu" + :on-click toggle-dropdown} + [:span {:class (stl/css :dropdown-title)} (tr "labels.comments")] + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :icon-dropdown)}]] [:& dropdown {:show @show-dropdown? :on-close hide-dropdown} @@ -83,7 +89,7 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-all-comments")] (when (or (= :all cmode) (nil? cmode)) - [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css-case :dropdown-element true @@ -92,8 +98,7 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-your-comments")] (when (= :yours cmode) - [:span {:class (stl/css :icon)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css-case :dropdown-element true @@ -102,8 +107,7 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-mentions")] (when (= :mentions cmode) - [:span {:class (stl/css :icon)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css :separator)}] @@ -114,8 +118,7 @@ :on-click update-show} [:span {:class (stl/css :label)} (tr "labels.hide-resolved-comments")] (when (= :pending cshow) - [:span {:class (stl/css :icon)} - deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css :separator)}] @@ -126,7 +129,7 @@ :on-click update-options} [:span {:class (stl/css :label)} (tr "labels.show-comments-list")] (when show-sidebar? - [:span {:class (stl/css :icon)} deprecated-icon/tick])]]]])) + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])]]]])) (defn- update-thread-position diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss index c254f0b9ba..09ed1d98f7 100644 --- a/frontend/src/app/main/ui/viewer/comments.scss +++ b/frontend/src/app/main/ui/viewer/comments.scss @@ -4,91 +4,71 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/_borders.scss" as *; +// Only for the shared menu placeholders, which move to the DS with the +// menus and dropdowns. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; // COMMENT DROPDOWN ON HEADER +.view-options-wrapper { + position: relative; +} + .view-options { - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: flex; align-items: center; - position: relative; - gap: deprecated.$s-4; - height: deprecated.$s-32; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - background-color: var(--input-background-color); + border: none; + gap: var(--sp-xs); + block-size: $sz-32; + padding: var(--sp-s); + border-radius: $br-8; + background-color: var(--color-background-tertiary); cursor: pointer; } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; - padding: deprecated.$s-6; + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); + padding: px2rem(6); } .dropdown-title { - @include deprecated.body-small-typography; + @include use-typography("body-small"); flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .label { flex-grow: 1; - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .icon, .icon-dropdown { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } -} - -.icon-dropdown svg { - transform: rotate(90deg); + flex-shrink: 0; + color: var(--color-foreground-secondary); } .dropdown-element { @extend %dropdown-element-base; - .icon { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } - &:hover .label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } } .dropdown-element.selected { .label { - color: var(--input-foreground-color-active); - } - - .icon svg { - stroke: var(--input-foreground-color); + color: var(--color-foreground-primary); } } @@ -96,36 +76,41 @@ position: relative; block-size: var(--sp-xs); inline-size: calc(100% + var(--sp-s)); - border-top: $b-1 solid var(--color-background-quaternary); - left: calc(-1 * var(--sp-xs)); - margin-top: var(--sp-s); + border-block-start: $b-1 solid var(--color-background-quaternary); + inset-inline-start: calc(-1 * var(--sp-xs)); + margin-block-start: var(--sp-s); } // FLOATING COMMENT .viewer-comments-container { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: deprecated.$z-index-1; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 100%; + block-size: 100%; + + // TODO: no DS z-index token for 1 (floating elements). + z-index: 1; } .threads { position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; } // COMMENT SIDEBAR .comments-sidebar { position: absolute; - right: 0; - top: deprecated.$s-44; - width: var(--right-sidebar-width); - height: calc(100vh - deprecated.$s-48); - z-index: deprecated.$z-index-10; - background-color: var(--panel-background-color); + inset-inline-end: 0; + inset-block-start: px2rem(44); + inline-size: var(--right-sidebar-width); + block-size: calc(100vh - #{$sz-48}); + background-color: var(--color-background-primary); + + // TODO: no DS z-index token for 10; it keeps the list above the viewer + // content. + z-index: 10; } .settings-bar-inside { @@ -133,5 +118,5 @@ } .comments-section { - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } From a5fd2df1f053d92ffbf266fd737d9beceee852e2 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 11:38:33 +0100 Subject: [PATCH 151/325] :recycle: Make the grid track direction icon and the swap groups buttons (#12476) * :recycle: Make the grid track direction icon a button The direction icon at the start of each grid track value field selected that column or row from a div with a click handler: no button role, no name and no keyboard access. Make it a button labeled "Select column" or "Select row", keeping its class and handler, and reset the browser border so it looks the same. It stays a raw button: it is a segment of the track field, not a DS button. Add the workspace.layout-grid.editor.select-column and select-row keys (en, es). AI-assisted-by: claude-opus-5-5 * :recycle: Make the component swap panel groups buttons The groups of the component swap panel opened from a div with a click handler: no button role and no keyboard access. Make each group a button, keeping its class, title and handler, and reset the browser button styles so it looks the same. Its name is the visible group name. Add a visual test with a fixture whose components sit in two groups, so the swap panel lists them. AI-assisted-by: claude-opus-5-5 --- .../workspace/get-file-9066-swap-groups.json | 1935 +++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 25 + .../sidebar/options/menus/component.cljs | 7 +- .../sidebar/options/menus/component.scss | 5 + .../options/menus/layout_container.cljs | 10 +- .../options/menus/layout_container.scss | 1 + frontend/translations/en.po | 8 + frontend/translations/es.po | 8 + 8 files changed, 1994 insertions(+), 5 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-file-9066-swap-groups.json diff --git a/frontend/playwright/data/workspace/get-file-9066-swap-groups.json b/frontend/playwright/data/workspace/get-file-9066-swap-groups.json new file mode 100644 index 0000000000..d20df8426c --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-9066-swap-groups.json @@ -0,0 +1,1935 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 10", + "~:revn": 10, + "~:modified-at": "~m1729604566305", + "~:id": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:is-shared": false, + "~:version": 55, + "~:project-id": "~u3ffbd505-2f26-800f-8004-f34da98bdad8", + "~:created-at": "~m1729594560852", + "~:data": { + "~:pages": [ + "~ue179d9df-de35-80bf-8005-283bbd5516b1" + ], + "~:pages-index": { + "~ue179d9df-de35-80bf-8005-283bbd5516b1": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd": { + "~#shape": { + "~:y": 589.9999999999999, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "E", + "~:width": 291.0000000000002, + "~:type": "~:frame", + "~:touched": { + "~#set": [] + }, + "~:points": [ + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 784.0000000000003 + } + }, + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 784.0000000000003 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1408.3799999999999, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999, + "~:width": 291.0000000000002, + "~:height": 194.00000000000045, + "~:x1": 1408.3799999999999, + "~:y1": 589.9999999999999, + "~:x2": 1699.38, + "~:y2": 784.0000000000003 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 194.00000000000045, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd4" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc": { + "~#shape": { + "~:y": 793.0000000000007, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "F", + "~:width": 176, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1465.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 969.0000000000009 + } + }, + { + "~#point": { + "~:x": 1465.88, + "~:y": 969.0000000000009 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1465.88, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1465.88, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 1465.88, + "~:y1": 793.0000000000007, + "~:x2": 1641.88, + "~:y2": 969.0000000000009 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2" + ] + } + }, + "~u9b0db134-3c1b-800f-8005-285b833b92a1": { + "~#shape": { + "~:y": 599.0000000000002, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "A", + "~:width": 176, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:name-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 775.0000000000005 + } + } + ], + "~:component-root": true, + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u9b0db134-3c1b-800f-8005-285b833b92a1", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 881.1199999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 881.1199999999999, + "~:y1": 599.0000000000002, + "~:x2": 1057.12, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u9b0db134-3c1b-800f-8005-285b833b92a2" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf": { + "~#shape": { + "~:y": 599, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "C", + "~:width": 176, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 775.0000000000005 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1173.5, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 599, + "~:width": 176, + "~:height": 176.00000000000045, + "~:x1": 1173.5, + "~:y1": 599, + "~:x2": 1349.5, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176.00000000000045, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd6" + ] + } + }, + "~u9b0db134-3c1b-800f-8005-285b833b92a2": { + "~#shape": { + "~:y": 599.0000000000002, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 599.0000000000002 + } + }, + { + "~#point": { + "~:x": 1057.12, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 881.1199999999999, + "~:y": 775.0000000000005 + } + } + ], + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u9b0db134-3c1b-800f-8005-285b833b92a2", + "~:parent-id": "~u9b0db134-3c1b-800f-8005-285b833b92a1", + "~:frame-id": "~u9b0db134-3c1b-800f-8005-285b833b92a1", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#e1c1dc", + "~:stroke-opacity": 1, + "~:stroke-width": 1 + } + ], + "~:x": 881.1199999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1199999999999, + "~:y": 599.0000000000002, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 881.1199999999999, + "~:y1": 599.0000000000002, + "~:x2": 1057.12, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [ + { + "~:fill-color": "#f4f6ff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce": { + "~#shape": { + "~:y": 793.0000000000007, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "D", + "~:width": 176, + "~:type": "~:frame", + "~:touched": { + "~#set": [ + "~:name-group" + ] + }, + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 969.0000000000007 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 969.0000000000007 + } + } + ], + "~:component-root": true, + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-2830309d281e", + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283030a2d6be", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 1173.5, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176, + "~:x1": 1173.5, + "~:y1": 793.0000000000007, + "~:x2": 1349.5, + "~:y2": 969.0000000000007 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176, + "~:component-file": "~ue179d9df-de35-80bf-8005-28302f57b1b5", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd5" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb": { + "~#shape": { + "~:y": 589.9999999999999, + "~:layout-grid-columns": [ + { + "~:type": "~:percent", + "~:value": 33 + }, + { + "~:type": "~:percent", + "~:value": 33 + }, + { + "~:type": "~:percent", + "~:value": 33 + } + ], + "~:hide-fill-on-export": false, + "~:layout-gap-type": "~:multiple", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:layout": "~:grid", + "~:hide-in-viewer": true, + "~:name": "Group", + "~:layout-align-items": "~:center", + "~:width": 886, + "~:layout-grid-cells": { + "~ud874937d-e901-8094-8005-280b10c40285": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud874937d-e901-8094-8005-280b10c40285", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [ + "~u9b0db134-3c1b-800f-8005-285b833b92a1" + ] + }, + "~ud874937d-e901-8094-8005-280b1e40abf0": { + "~:justify-self": "~:auto", + "~:column": 3, + "~:id": "~ud874937d-e901-8094-8005-280b1e40abf0", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc" + ] + }, + "~ud874937d-e901-8094-8005-280b1e40abef": { + "~:justify-self": "~:auto", + "~:column": 3, + "~:id": "~ud874937d-e901-8094-8005-280b1e40abef", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd" + ] + }, + "~ud874937d-e901-8094-8005-280b1dbe21db": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud874937d-e901-8094-8005-280b1dbe21db", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf" + ] + }, + "~ud874937d-e901-8094-8005-280b10c44c06": { + "~:justify-self": "~:center", + "~:column": 1, + "~:id": "~ud874937d-e901-8094-8005-280b10c44c06", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:center", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0" + ] + }, + "~ud874937d-e901-8094-8005-280b1dbe21dc": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud874937d-e901-8094-8005-280b1dbe21dc", + "~:position": "~:manual", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce" + ] + } + }, + "~:layout-padding-type": "~:simple", + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 818.5, + "~:y": 590 + } + }, + { + "~#point": { + "~:x": 1704.5, + "~:y": 590 + } + }, + { + "~#point": { + "~:x": 1704.5, + "~:y": 978.0000000000011 + } + }, + { + "~#point": { + "~:x": 818.5, + "~:y": 978.0000000000011 + } + } + ], + "~:show-content": true, + "~:layout-item-h-sizing": "~:fix", + "~:proportion-lock": false, + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:auto", + "~:layout-justify-content": "~:center", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:layout-justify-items": "~:center", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca", + "~:layout-align-content": "~:center", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca", + "~:strokes": [], + "~:x": 818.5, + "~:proportion": 1, + "~:layout-grid-rows": [ + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + } + ], + "~:selrect": { + "~#rect": { + "~:x": 818.5, + "~:y": 589.9999999999999, + "~:width": 886, + "~:height": 388.000000000001, + "~:x1": 818.5, + "~:y1": 589.9999999999999, + "~:x2": 1704.5, + "~:y2": 978.0000000000009 + } + }, + "~:fills": [], + "~:layout-grid-dir": "~:column", + "~:flip-x": null, + "~:height": 388.000000000001, + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~u9b0db134-3c1b-800f-8005-285b833b92a1" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca": { + "~#shape": { + "~:y": 553, + "~:layout-grid-columns": [ + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + } + ], + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 975, + "~:layout-grid-cells": { + "~ud48fcd31-2d57-8057-8005-2711ee36df37": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee36df37", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 7, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed8cabd4": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed8cabd4", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 4, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed8cabd3": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed8cabd3", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 4, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ef8afebd": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ef8afebd", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 10, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e762253b": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e762253b", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e762253a": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e762253a", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 1, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711eea7b59a": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711eea7b59a", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 9, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711eea7b599": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711eea7b599", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 9, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee36df38": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee36df38", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 7, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ef8b1898": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ef8b1898", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 10, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e7627244": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e7627244", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711e7627243": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711e7627243", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 2, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711edc8124d": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711edc8124d", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 5, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed0fc9ed": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed0fc9ed", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 3, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711edc8124c": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711edc8124c", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 5, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ed0fc9ec": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ed0fc9ec", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 3, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee71bbca": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee71bbca", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 8, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee02246a": { + "~:justify-self": "~:auto", + "~:column": 2, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee02246a", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 6, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee71bbc9": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee71bbc9", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 8, + "~:row-span": 1, + "~:shapes": [] + }, + "~ud48fcd31-2d57-8057-8005-2711ee022469": { + "~:justify-self": "~:auto", + "~:column": 1, + "~:id": "~ud48fcd31-2d57-8057-8005-2711ee022469", + "~:position": "~:auto", + "~:column-span": 1, + "~:align-self": "~:auto", + "~:row": 6, + "~:row-span": 1, + "~:shapes": [] + } + }, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 774, + "~:y": 553 + } + }, + { + "~#point": { + "~:x": 1749, + "~:y": 553 + } + }, + { + "~#point": { + "~:x": 1749, + "~:y": 1015 + } + }, + { + "~#point": { + "~:x": 774, + "~:y": 1015 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bca", + "~:layout-justify-items": "~:start", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 774, + "~:proportion": 1, + "~:layout-grid-rows": [ + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + }, + { + "~:type": "~:flex", + "~:value": 1 + } + ], + "~:selrect": { + "~#rect": { + "~:x": 774, + "~:y": 553, + "~:width": 975, + "~:height": 462, + "~:x1": 774, + "~:y1": 553, + "~:x2": 1749, + "~:y2": 1015 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:layout-grid-dir": "~:row", + "~:flip-x": null, + "~:height": 462, + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd5": { + "~#shape": { + "~:y": 793.0000000000007, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 969.0000000000007 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 969.0000000000007 + } + } + ], + "~:shape-ref": "~u6ad3e6b9-c5a0-80cf-8005-2830309d2825", + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd5", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bce", + "~:strokes": [], + "~:x": 1173.5, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176, + "~:x1": 1173.5, + "~:y1": 793.0000000000007, + "~:x2": 1349.5, + "~:y2": 969.0000000000007 + } + }, + "~:fills": [ + { + "~:fill-color": "#c1c9e1", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd4": { + "~#shape": { + "~:y": 598.9999999999999, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176.00000000000023, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1465.8799999999999, + "~:y": 598.9999999999999 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 598.9999999999999 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 775.0000000000003 + } + }, + { + "~#point": { + "~:x": 1465.8799999999999, + "~:y": 775.0000000000003 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd4", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:strokes": [], + "~:x": 1465.8799999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1465.8799999999999, + "~:y": 598.9999999999999, + "~:width": 176.00000000000023, + "~:height": 176.00000000000045, + "~:x1": 1465.8799999999999, + "~:y1": 598.9999999999999, + "~:x2": 1641.88, + "~:y2": 775.0000000000003 + } + }, + "~:fills": [ + { + "~:fill-color": "#f4f6ff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000045, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd7": { + "~#shape": { + "~:y": 793.0000000000006, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 969.0000000000006 + } + }, + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 969.0000000000006 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd7", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:strokes": [], + "~:x": 881.1200000000001, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006, + "~:width": 176, + "~:height": 176, + "~:x1": 881.1200000000001, + "~:y1": 793.0000000000006, + "~:x2": 1057.1200000000001, + "~:y2": 969.0000000000006 + } + }, + "~:fills": [ + { + "~:fill-color": "#c1c9e1", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd6": { + "~#shape": { + "~:y": 599, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1173.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 599 + } + }, + { + "~#point": { + "~:x": 1349.5, + "~:y": 775.0000000000005 + } + }, + { + "~#point": { + "~:x": 1173.5, + "~:y": 775.0000000000005 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd6", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:strokes": [], + "~:x": 1173.5, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1173.5, + "~:y": 599, + "~:width": 176, + "~:height": 176.00000000000045, + "~:x1": 1173.5, + "~:y1": 599, + "~:x2": 1349.5, + "~:y2": 775.0000000000005 + } + }, + "~:fills": [ + { + "~:fill-color": "#c9e1c1", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000045, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0": { + "~#shape": { + "~:y": 793.0000000000006, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": true, + "~:name": "B", + "~:width": 176, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 793.0000000000006 + } + }, + { + "~#point": { + "~:x": 1057.1200000000001, + "~:y": 969.0000000000006 + } + }, + { + "~#point": { + "~:x": 881.1200000000001, + "~:y": 969.0000000000006 + } + } + ], + "~:component-root": true, + "~:show-content": true, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:component-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcb", + "~:strokes": [], + "~:x": 881.1200000000001, + "~:main-instance": true, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 881.1200000000001, + "~:y": 793.0000000000006, + "~:width": 176, + "~:height": 176, + "~:x1": 881.1200000000001, + "~:y1": 793.0000000000006, + "~:x2": 1057.1200000000001, + "~:y2": 969.0000000000006 + } + }, + "~:fills": [], + "~:flip-x": null, + "~:height": 176, + "~:component-file": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:flip-y": null, + "~:shapes": [ + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd7" + ] + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3": { + "~#shape": { + "~:y": 589.9999999999999, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 291.0000000000002, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 589.9999999999999 + } + }, + { + "~#point": { + "~:x": 1699.38, + "~:y": 784.0000000000003 + } + }, + { + "~#point": { + "~:x": 1408.3799999999999, + "~:y": 784.0000000000003 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:strokes": [], + "~:x": 1408.3799999999999, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1408.3799999999999, + "~:y": 589.9999999999999, + "~:width": 291.0000000000002, + "~:height": 194.00000000000045, + "~:x1": 1408.3799999999999, + "~:y1": 589.9999999999999, + "~:x2": 1699.38, + "~:y2": 784.0000000000003 + } + }, + "~:fills": [ + { + "~:fill-color": "#e1c1dc", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:ry": 0, + "~:height": 194.00000000000045, + "~:flip-y": null + } + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2": { + "~#shape": { + "~:y": 793.0000000000007, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 176, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1465.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 793.0000000000007 + } + }, + { + "~#point": { + "~:x": 1641.88, + "~:y": 969.0000000000009 + } + }, + { + "~#point": { + "~:x": 1465.88, + "~:y": 969.0000000000009 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:constraints-v": "~:scale", + "~:constraints-h": "~:scale", + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd2", + "~:parent-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:frame-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#e1c1dc", + "~:stroke-opacity": 1, + "~:stroke-width": 1, + "~:color": "#e1c1dc", + "~:opacity": 1 + } + ], + "~:x": 1465.88, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 1465.88, + "~:y": 793.0000000000007, + "~:width": 176, + "~:height": 176.00000000000023, + "~:x1": 1465.88, + "~:y1": 793.0000000000007, + "~:x2": 1641.88, + "~:y2": 969.0000000000009 + } + }, + "~:fills": [ + { + "~:fill-color": "#f4f6ff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 176.00000000000023, + "~:flip-y": null + } + } + }, + "~:id": "~ue179d9df-de35-80bf-8005-283bbd5516b1", + "~:name": "Page 1" + } + }, + "~:id": "~ue179d9df-de35-80bf-8005-283bbd5516b0", + "~:options": { + "~:components-v2": true + }, + "~:components": { + "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8", + "~:name": "F", + "~:path": "", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51", + "~:name": "E", + "~:path": "Letters", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014", + "~:name": "C", + "~:path": "Others", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + }, + "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793": { + "~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793", + "~:name": "B", + "~:path": "", + "~:modified-at": "~m1729604566311", + "~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0", + "~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1" + } + } + } +} diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 2423ac72e0..498663ae09 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -393,6 +393,31 @@ test.describe("Component options", () => { }); }); + test("Shows the groups of the swap panel", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-9066-swap-groups.json", + ); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + await workspace.layers.getByText("A", { exact: true }).click(); + await workspace.rightSidebar.getByRole("button", { name: /^A / }).click(); + + await expect(workspace.rightSidebar.getByText("Letters")).toBeVisible(); + await workspace.rightSidebar.getByText("Letters").hover(); + await expect(workspace.rightSidebar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + test("Shows the annotation actions of a main component", async ({ page }) => { const workspace = new WorkspacePage(page); await workspace.setupEmptyFile(); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 9f30b24036..5b4f23d1db 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -625,9 +625,10 @@ [{:keys [item on-enter-group]}] (let [group-name (:name item) on-group-click #(on-enter-group group-name)] - [:div {:class (stl/css :swap-group) - :on-click on-group-click - :title group-name} + [:button {:type "button" + :class (stl/css :swap-group) + :on-click on-group-click + :title group-name} [:span {:class (stl/css :swap-group-name)} (cpn/last-path group-name)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index 77403735b7..5fd8e97fc0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -330,6 +330,11 @@ .swap-group { @include t.use-typography("body-small"); + inline-size: 100%; + padding: 0; + border: none; + background: none; + text-align: start; cursor: pointer; display: grid; grid-template-columns: 1fr var(--sp-m); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 53b53d711c..430f46526f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -986,8 +986,14 @@ :on-pointer-leave pointer-leave} [:div {:class (stl/css :track-info-container)} - [:div {:class (stl/css :track-info-dir-icon) - :on-click handle-select-track} + ;; Raw button on purpose: it is the start segment of the track + ;; value field, not a DS button. + [:button {:type "button" + :class (stl/css :track-info-dir-icon) + :aria-label (if is-column + (tr "workspace.layout-grid.editor.select-column") + (tr "workspace.layout-grid.editor.select-row")) + :on-click handle-select-track} [:> icon* {:icon-id (if is-column i/flex-vertical i/flex-horizontal) :class (stl/css :track-info-dir)}]] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index c92abb2dc9..e3a1f34117 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -235,6 +235,7 @@ display: flex; align-items: center; cursor: pointer; + border: none; border-radius: $br-8 0 0 $br-8; background-color: var(--color-background-tertiary); padding: 0 var(--sp-s); diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 577cc740a1..63fcfeddb3 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6803,6 +6803,14 @@ msgstr "Top padding" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Vertical padding" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-column" +msgstr "Select column" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-row" +msgstr "Select row" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs msgid "workspace.layout-grid.editor.track-options" msgstr "Track options" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 434c88d4ba..195a1912eb 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6887,6 +6887,14 @@ msgstr "Padding superior" msgid "workspace.layout-grid.editor.padding.vertical" msgstr "Padding vertical" +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-column" +msgstr "Seleccionar columna" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +msgid "workspace.layout-grid.editor.select-row" +msgstr "Seleccionar fila" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs msgid "workspace.layout-grid.editor.track-options" msgstr "Opciones de la pista" From 577cbe43e425677136706335cca93bbe95338259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 26 Oct 2026 12:36:13 +0100 Subject: [PATCH 152/325] :bug: Validate the text payload before pasting it (#12612) Text copied in Penpot carries its content in a data attribute of the clipboard HTML, which plugins and other apps can rewrite. The v3 editor and canvas paste used it as is, so bad data could reach WASM and the file and break the shape. The payload is now dropped, and the paste falls back to plain text in the caret style, unless its content passes the text shape schema, its span geometry holds only safe numbers and its file and team ids are uuids. Payloads in HTML over the paste size limit are not decoded. Copied text shapes pasted into the editor get the same checks, plus a numeric position; when they fail, nothing is pasted. Closes #12167 AI-assisted-by: claude-opus-5-5 --- .../src/app/main/data/workspace/texts.cljs | 41 ++++++++++------- frontend/src/app/util/text/clipboard.cljs | 37 ++++++++++++++-- .../util_text_clipboard_test.cljs | 44 +++++++++++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index c50af92813..bc077f92c4 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -22,6 +22,7 @@ [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.changes :as dch] @@ -1617,9 +1618,6 @@ (rx/catch on-clipboard-error)) (rx/empty))))) -;; Bigger HTML is pasted as plain text rather than walked. -(def ^:private max-paste-html-length 1000000) - (defn payload-content "The content of a Penpot text `payload` without what the current file cannot reach. A payload without team counts as coming from the current team." @@ -1636,7 +1634,10 @@ [text] (when (str/starts-with? (str/trim (or text "")) "{") (let [data (ex/ignoring (t/decode-str text))] - (when (and (map? data) (= :copied-shapes (:type data))) + (when (and (map? data) + (= :copied-shapes (:type data)) + (map? (:objects data)) + (set? (:selected data))) data)))) (defn copied-text-geometry @@ -1660,22 +1661,30 @@ (mapv #(mapv (constantly nil) (:children %)) (-> content :children first :children)))) +(defn- pasteable-text? + "True when a copied text shape, which other apps or plugins may have written, can + be pasted into the text editor." + [{:keys [x y content]}] + (and (number? x) (number? y) (cts/valid-content? content))) + (defn- copied-shapes->payload "A text payload joining the content of the copied text shapes, top to bottom and - left to right; nil when none of the copied shapes is a text." + left to right; nil when none of the copied shapes is a text or it is not valid." [{:keys [objects selected file-id team-id text-geometry]}] (let [texts (->> selected (keep #(get objects %)) - (filter cfh/text-shape?) - (sort-by (juxt :y :x)))] - (when (seq texts) - (cond-> {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) texts))} - (some? text-geometry) - (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))))) + (filter cfh/text-shape?))] + (when (and (seq texts) (every? pasteable-text? texts)) + (let [texts (sort-by (juxt :y :x) texts) + payload (cond-> {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))} + (some? text-geometry) + (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))] + (when (text-clipboard/valid-payload? payload) + payload))))) (defn- penpot-paste [state payload] @@ -1700,7 +1709,7 @@ (some->> (copied-shapes->payload shapes) (penpot-paste state)) :else - (when-let [fragment (or (when (and html-paste? (< 0 (count html) max-paste-html-length)) + (when-let [fragment (or (when (and html-paste? (< 0 (count html) text-clipboard/max-paste-html-length)) (some-> (text-clipboard/html->fragment html) (text-clipboard/without-overrides))) (text-clipboard/text->fragment text))] diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index 35df340afe..f8368aa34a 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -11,7 +11,9 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.fonts :as cfnt] + [app.common.schema :as sm] [app.common.transit :as t] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.types.typography :as ctt] [cuerdas.core :as str])) @@ -277,6 +279,29 @@ (def ^:private payload-re #"data-penpot-text=\"([A-Za-z0-9+/=]*)\"") +;; Bigger HTML is pasted as plain text rather than read. +(def max-paste-html-length 1000000) + +(def ^:private schema:origin + [:map {:closed true} + [:x ::sm/safe-number] + [:y ::sm/safe-number] + [:box [:map {:closed true} + [:width ::sm/safe-number] + [:height ::sm/safe-number]]]]) + +(def ^:private valid-geometry? + (sm/lazy-validator + [:map {:closed true} + [:origins [:vector [:vector [:maybe schema:origin]]]]])) + +(defn valid-payload? + "True when the `content` and `geometry` of a payload, which other apps or plugins + may have written, are safe to paste into a text shape." + [{:keys [content geometry]}] + (and (cts/valid-content? content) + (or (nil? geometry) (valid-geometry? geometry)))) + (defn- encode-base64 [s] (->> (.encode (js/TextEncoder.) s) @@ -307,11 +332,15 @@ (defn html->payload "The Penpot payload in `html`, or nil when it has none." [html] - (when-let [[_ encoded] (some->> html (re-find payload-re))] - (let [payload (ex/ignoring (t/decode-str (decode-base64 encoded)))] + (when-let [[_ encoded] (when (< (count html) max-paste-html-length) + (some->> html (re-find payload-re)))] + (let [{:keys [type file-id team-id] :as payload} + (ex/ignoring (t/decode-str (decode-base64 encoded)))] (when (and (map? payload) - (= :copied-text (:type payload)) - (map? (:content payload))) + (= :copied-text type) + (uuid? file-id) + (or (nil? team-id) (uuid? team-id)) + (valid-payload? payload)) payload)))) ;; --- Pasting into another file or team diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index e5534a1517..88b1cbdcfe 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -228,6 +228,50 @@ (t/is (nil? (clipboard/html->payload ""))) (t/is (nil? (clipboard/html->payload nil))))) +(defn- tampered-html + "Payload markup carrying `payload` as is, as a plugin could write it." + [{:keys [content file-id team-id geometry]}] + (clipboard/payload->html content file-id team-id geometry)) + +(def ^:private valid-payload + {:content (text-content gradient-span) :file-id file-id :team-id team-id}) + +(t/deftest invalid-payload + (t/testing "a valid payload is read" + (t/is (some? (clipboard/html->payload (tampered-html valid-payload))))) + + (t/testing "a payload with a broken envelope is dropped" + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :file-id "file"))))) + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :team-id 42)))))) + + (t/testing "a payload whose content a text shape cannot hold is dropped" + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc-in valid-payload [:content :children 0 :children] []))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content (text-content (assoc gradient-span :text 42))))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content + (text-content (assoc gradient-span :fills [{:fill-color "red"}])))))))) + + (t/testing "a payload with geometry that is not plain numbers is dropped" + (let [origin {:x 12.5 :y 0.2 :box {:width 200 :height 40}}] + (t/is (some? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :x "12")]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :y ##NaN)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc-in origin [:box :width] nil)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]] :extra 1})))))))) + +(t/deftest oversized-payload + (t/testing "HTML over the paste size limit is not decoded" + (let [hidden (clipboard/payload->html (text-content gradient-span) file-id team-id)] + (t/is (nil? (clipboard/html->payload + (str hidden (apply str (repeat clipboard/max-paste-html-length "x"))))))))) + ;; --- Clean-up (def ^:private image-fill From 48a5875a7161a03fc16c4ce723bf4227f22a70f1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 12:48:43 +0100 Subject: [PATCH 153/325] :recycle: Use DS icon buttons in the viewer board navigation (#12527) * :recycle: Use DS icon buttons in the viewer board navigation The previous, next and first board buttons of the viewer become secondary DS icon buttons with the DS arrow and reload icons. They keep their size and the viewer background at rest; on hover they take the DS secondary look. The first board button had no accessible name; it is now labelled. The position classes move to the tooltip wrappers, so the fullscreen and comments sidebar offsets still apply. New key: viewer.go-to-first-board (en, es). A visual test covers the buttons at rest and on hover. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the file no longer imports refactor/. The section scrollbar uses the DS custom-scrollbar mixin; the text selection and placeholder styles that %new-scrollbar gave to the comments layer stay, with DS tokens of the same value. An invalid padding-right with four values, which the browser ignored, is gone. The DS body-small typography differs from the old one only in a more compact line-height, so the board counter sits 1px higher. The z-index values (10, 2) have no DS token and keep a TODO. The .viewer-header, .not-fixed, .fixed and .frame-children rules are not used by this module and are commented out with a TODO. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the viewer The SCSS refactor of the viewer left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .viewer-header, .not-fixed, .fixed, .frame-children g. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 44 +++++ frontend/src/app/main/ui/viewer.cljs | 41 ++-- frontend/src/app/main/ui/viewer.scss | 177 +++++++++--------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 165 insertions(+), 105 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 95b3e58edc..65ffbe4af8 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -222,3 +222,47 @@ test.describe("Comments filter", () => { }); }); }); + +test.describe("Board navigation", () => { + test("Shows the previous, next and first board buttons", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithMultipleBoards(); + await viewerPage.goToViewer({ + fileId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0", + pageId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3", + }); + + const next = page.getByRole("button", { name: "Next" }); + await expect(next).toBeVisible(); + await page.mouse.move(640, 300); + // Wait for the slide-in transition of the controls. + await page.waitForTimeout(800); + // First board: the next and first board buttons. + await expect(page).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await next.hover(); + await expect(next).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await next.click(); + const previous = page.getByRole("button", { name: "Previous" }); + await previous.hover(); + await expect(previous).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + const first = page.locator("[class*='viewer-bottom'] button"); + await first.hover(); + await expect(first).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index f3cb52db0b..2422d347c2 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -22,9 +22,10 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.modal :refer [modal-container*]] [app.main.ui.viewer.comments :refer [comments-layer comments-sidebar*]] [app.main.ui.viewer.header :as header] @@ -99,23 +100,33 @@ go-first-frame (mf/use-fn #(st/emit! dv/select-first-frame))] [:* (when (pos? index) - [:button {:class (stl/css-case :viewer-go-prev true - :left-bar left-bar) - :on-click go-prev-frame - :aria-label (tr "labels.previous")} - deprecated-icon/arrow]) + [:> icon-button* {:variant "secondary" + :class (stl/css :viewer-go-button) + :tooltip-class (stl/css-case :viewer-go-prev true + :left-bar left-bar) + :tooltip-placement "right" + :aria-label (tr "labels.previous") + :icon i/arrow-left + :on-click go-prev-frame}]) (when (< (+ index 1) num-frames) - [:button {:class (stl/css-case :viewer-go-next true - :comment-sidebar comment-sidebar - :right-bar right-bar) - :on-click go-next-frame - :aria-label (tr "labels.next")} - deprecated-icon/arrow]) + [:> icon-button* {:variant "secondary" + :class (stl/css :viewer-go-button) + :tooltip-class (stl/css-case :viewer-go-next true + :comment-sidebar comment-sidebar + :right-bar right-bar) + :tooltip-placement "left" + :aria-label (tr "labels.next") + :icon i/arrow-right + :on-click go-next-frame}]) [:div {:class (stl/css-case :viewer-bottom true :left-bar left-bar)} - [:button {:on-click go-first-frame - :class (stl/css :reset-button)} - deprecated-icon/reload] + [:> icon-button* {:variant "secondary" + :class (stl/css :reset-button) + :tooltip-class (stl/css :reset-button-wrapper) + :tooltip-placement "top-right" + :aria-label (tr "viewer.go-to-first-board") + :icon i/reload + :on-click go-first-frame}] [:span {:class (stl/css :counter)} (str/join " / " [(+ index 1) num-frames])] [:span]]])) diff --git a/frontend/src/app/main/ui/viewer.scss b/frontend/src/app/main/ui/viewer.scss index d727764672..bef25833d1 100644 --- a/frontend/src/app/main/ui/viewer.scss +++ b/frontend/src/app/main/ui/viewer.scss @@ -4,12 +4,16 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; .viewer-layout { - height: 100vh; + block-size: 100vh; display: grid; - grid-template-rows: deprecated.$s-48 auto; + grid-template-rows: $sz-48 auto; grid-template-columns: 1fr; user-select: none; } @@ -18,41 +22,41 @@ overflow: hidden; grid-row: 2 / span 1; display: grid; - grid-template-rows: deprecated.$s-252 auto; + grid-template-rows: $sz-252 auto; grid-template-columns: 1fr; - background-color: var(--viewer-background-color); + background-color: var(--color-background-secondary); } .empty-state { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--empty-message-foreground-color); + color: var(--color-foreground-secondary); display: grid; place-items: center; - height: 100%; - width: 100%; -} - -.viewer-header { - grid-row: 1 / span 1; + block-size: 100%; + inline-size: 100%; } .inspect-layout { display: grid; - grid-template-rows: deprecated.$s-48 auto; + grid-template-rows: $sz-48 auto; grid-template-columns: 1fr; - height: 100vh; - margin-top: 0; + block-size: 100vh; + margin-block-start: 0; user-select: none; } .thumbnails-close { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; grid-row: 1 / span 2; grid-column: 1 / span 1; - z-index: deprecated.$z-index-10; - background-color: var(--overlay-color); + + // TODO: no DS z-index token for 10; it keeps the backdrop above the + // viewer content (z-index 2), level with the thumbnails panel. + z-index: 10; + background-color: var(--color-overlay-default); } .thumbnails-close.invisible { @@ -60,98 +64,104 @@ } .viewer-section { - @extend %new-scrollbar; + @include custom-scrollbar($include-selection: false, $include-placeholder: false); grid-row: 1 / span 2; grid-column: 1 / span 1; display: flex; align-items: center; flex-wrap: wrap; - height: calc(100vh - deprecated.$s-48); + block-size: calc(100vh - #{$sz-48}); overflow: auto; + + // The comments layer renders inside the section: its text selection and + // placeholders keep the styles they had. + ::selection { + background: var(--color-accent-background-select); + color: var(--color-static-white); + } + + ::placeholder { + @include use-typography("body-small"); + + color: var(--color-foreground-secondary); + } } .inspect-layout .viewer-section { flex-wrap: nowrap; - margin-top: 0; - height: 100%; + margin-block-start: 0; + block-size: 100%; overflow: hidden; } +// Tooltip wrappers of the previous and next buttons. .viewer-go-prev, .viewer-go-next { - @extend %button-secondary; - @include deprecated.flex-center; - position: absolute; - right: deprecated.$s-8; - height: deprecated.$s-64; - width: deprecated.$s-32; - top: calc(50vh - deprecated.$s-32); - z-index: deprecated.$z-index-2; - background-color: var(--viewer-controls-background-color); + inset-inline-end: var(--sp-s); + inset-block-start: calc(50vh - #{$sz-32}); + + // TODO: no DS z-index token for 2; it keeps the controls above the + // viewer section. + z-index: 2; transition: transform 400ms ease 300ms; +} - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } +.viewer-go-button { + --button-height: #{$sz-64}; + --button-bg-color: var(--color-background-primary); + --button-border-color: var(--color-background-tertiary); } .viewer-go-next.comment-sidebar { - right: deprecated.$s-280; + inset-inline-end: #{px2rem(280)}; } .viewer-go-prev { - left: deprecated.$s-8; - right: unset; - - svg { - transform: rotate(180deg); - } + inset-inline-start: var(--sp-s); + inset-inline-end: unset; } .viewer-bottom { position: fixed; - bottom: 0; + inset-block-end: 0; display: flex; align-items: flex-start; justify-content: space-between; - width: 100%; - height: deprecated.$s-40; - padding-right: 0 deprecated.$s-8 deprecated.$s-40 deprecated.$s-8; + inline-size: 100%; + block-size: $sz-40; transition: transform 400ms ease 300ms; - z-index: deprecated.$z-index-2; + + // TODO: no DS z-index token for 2; it keeps the controls above the + // viewer section. + z-index: 2; pointer-events: none; } +.reset-button-wrapper { + margin-inline-start: var(--sp-s); +} + .reset-button { - @extend %button-secondary; - @include deprecated.flex-center; + --button-width: #{$sz-28}; + --button-bg-color: var(--color-background-primary); + --button-border-color: var(--color-background-tertiary); - height: deprecated.$s-32; - width: deprecated.$s-28; - margin-left: deprecated.$s-8; - background-color: var(--viewer-controls-background-color); pointer-events: all; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } } .counter { - @include deprecated.flex-center; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - border-radius: deprecated.$br-8; - width: deprecated.$s-64; - height: deprecated.$s-32; - color: var(--viewer-thumbnails-control-foreground-color); - background-color: var(--viewer-controls-background-color); + display: flex; + justify-content: center; + align-items: center; + border-radius: $br-8; + inline-size: $sz-64; + block-size: $sz-32; + color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); } .viewer-wrapper { @@ -169,8 +179,8 @@ .viewer-overlay-background { position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; &.visible { background-color: rgb(0 0 0 / 0.2); @@ -185,24 +195,11 @@ clip-path: inset(0 0 0 0); grid-column: 1 / 1; grid-row: 1 / 1; - - .not-fixed { - position: absolute; - } - - .fixed { - position: fixed; - pointer-events: none; - - .frame-children g { - pointer-events: auto; - } - } } /** FULLSCREEN */ [data-fullscreen="true"] .viewer-bottom { - transform: translateY(deprecated.$s-40); + transform: translateY($sz-40); } [data-force-visible="true"] .viewer-bottom { @@ -210,20 +207,20 @@ } [data-fullscreen="true"] .viewer-go-next { - transform: translateX(deprecated.$s-40); + transform: translateX($sz-40); } [data-fullscreen="true"] .viewer-go-prev { - transform: translateX(-#{deprecated.$s-40}); + transform: translateX(-#{$sz-40}); } [data-fullscreen="true"] .viewer-content { - transform: translateY(-#{deprecated.$s-48}); - height: 100vh; + transform: translateY(-#{$sz-48}); + block-size: 100vh; } [data-fullscreen="true"] .viewer-section { - height: 100vh; + block-size: 100vh; } [data-force-visible="true"] .viewer-go-next { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 63fcfeddb3..dde95de91f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6091,6 +6091,10 @@ msgstr "No boards found on the page." msgid "viewer.frame-not-found" msgstr "Board not found." +#: src/app/main/ui/viewer.cljs:127 +msgid "viewer.go-to-first-board" +msgstr "Go to the first board" + #: src/app/main/ui/viewer/header.cljs:341 msgid "viewer.header.comments-section" msgstr "Comments (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 195a1912eb..1bd162954c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6172,6 +6172,10 @@ msgstr "No se ha encontrado ningún tablero." msgid "viewer.frame-not-found" msgstr "No se encuentra el tablero." +#: src/app/main/ui/viewer.cljs:127 +msgid "viewer.go-to-first-board" +msgstr "Ir al primer tablero" + #: src/app/main/ui/viewer/header.cljs:341 msgid "viewer.header.comments-section" msgstr "Comentarios (%s)" From e24e9dc747cc20b8f342731dac927fa6574595a9 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 14:39:06 +0100 Subject: [PATCH 154/325] :recycle: Use DS buttons and icons in the viewer header (#12523) * :recycle: Use DS buttons and icons in the viewer header The section buttons, full screen and edit become ghost DS icon buttons (the last two were spans), with aria-pressed where they toggle. Share and "Log in or sign up" become DS buttons. The zoom widget becomes a button that toggles its menu, like the workspace one, with DS zoom buttons inside. The breadcrumb and the current board name become buttons with aria-expanded, and the page menu moves next to its button. The arrow and tick icons use DS icons. The logo stays: it is brand art. A visual test covers the header in two sections and both menus. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer header styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties, and the button placeholders go away. The section buttons column goes from a fixed 92px to auto, since the three 32px DS buttons no longer fit; the 1fr side columns keep it centered. The zoom widget follows the workspace header one. The 11px project name becomes the 12px DS headline-small. The global .selected rule also highlighted the current page in the page menu: that row keeps its highlight through its own rule. The refactor/ import stays only for the shared menu placeholders; the options z-index (10) has no DS token and keeps a TODO. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 56 +++ frontend/src/app/main/ui/viewer/header.cljs | 130 ++++--- frontend/src/app/main/ui/viewer/header.scss | 329 +++++++----------- 3 files changed, 258 insertions(+), 257 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 65ffbe4af8..432b3df9c6 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -266,3 +266,59 @@ test.describe("Board navigation", () => { }); }); }); + +test.describe("Viewer header", () => { + test("Shows the header buttons and its menus", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + + const header = page.locator("header").first(); + await expect(header).toBeVisible(); + await page.mouse.move(640, 600); + // Interactions section. + await expect(header).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + // Zoom menu. + await page.getByTitle("Zoom").click(); + await expect(page.getByText("Fit - Scale down to fit")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(page).toHaveScreenshot({ + clip: { x: 760, y: 0, width: 520, height: 260 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + await page.keyboard.press("Escape"); + await page.mouse.click(640, 600); + + // Sitemap menu. + await page + .locator("#current-frame") + .locator("..") + .locator("div") + .first() + .click(); + await page.mouse.move(640, 600); + await expect(page).toHaveScreenshot({ + clip: { x: 0, y: 0, width: 520, height: 200 }, + maxDiffPixelRatio: 0, + threshold: 0, + }); + await page.mouse.click(640, 600); + + // Comments section, with its button selected. + await viewerPage.showComments(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index 3d1404373a..e0907d26aa 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -14,6 +14,9 @@ [app.main.data.viewer.shortcuts :as sc] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.icons :as deprecated-icon] @@ -47,11 +50,11 @@ (let [open* (mf/use-state false) open? (deref open*) - open-dropdown + toggle-dropdown (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! open* true))) + (swap! open* not))) close-dropdown (mf/use-fn @@ -73,28 +76,31 @@ (dom/stop-propagation event) (on-decrease)))] - [:div {:class (stl/css-case :zoom-widget true - :selected open?) - :on-click open-dropdown - :title (tr "workspace.header.zoom")} - [:span {:class (stl/css :label)} (fmt/format-percent zoom)] + [:div {:class (stl/css :zoom-widget-wrapper)} + [:button {:type "button" + :class (stl/css-case :zoom-widget true + :selected open?) + :on-click toggle-dropdown + :title (tr "workspace.header.zoom") + :aria-expanded open?} + [:span {:class (stl/css :label)} (fmt/format-percent zoom)]] [:& dropdown {:show open? :on-close close-dropdown} [:ul {:class (stl/css :dropdown)} [:li {:class (stl/css :basic-zoom-bar)} [:span {:class (stl/css :zoom-btns)} - [:button {:class (stl/css :zoom-btn) - :on-click on-decrease} - [:span {:class (stl/css :zoom-icon)} - deprecated-icon/remove-icon]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.decrease-zoom") + :on-click on-decrease + :icon i/remove}] [:p {:class (stl/css :zoom-text)} (fmt/format-percent zoom)] - [:button {:class (stl/css :zoom-btn) - :on-click on-increase} - [:span {:class (stl/css :zoom-icon)} - deprecated-icon/add]]] - [:button {:class (stl/css :reset-btn) - :on-click on-zoom-reset} + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.increase-zoom") + :on-click on-increase + :icon i/add}]] + [:> button* {:variant "ghost" + :on-click on-zoom-reset} (tr "workspace.header.reset-zoom")]] [:li {:class (stl/css :zoom-option) @@ -188,27 +194,30 @@ :on-fullscreen toggle-fullscreen}] (when (:in-team permissions) - [:span {:on-click go-to-workspace - :class (stl/css :edit-btn)} - deprecated-icon/curve]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.edit") + :icon i/curve + :on-click go-to-workspace}]) - [:span {:title (tr "viewer.header.fullscreen") - :class (stl/css-case :fullscreen-btn true - :selected fullscreen?) - :on-click toggle-fullscreen} - deprecated-icon/expand] + [:> icon-button* {:variant "ghost" + :aria-label (tr "viewer.header.fullscreen") + :aria-pressed (boolean fullscreen?) + :icon i/expand + :on-click toggle-fullscreen}] (when (:in-team permissions) - [:button {:on-click open-share-dialog - :class (stl/css :share-btn)} + [:> button* {:variant "primary" + :class (stl/css :share-btn) + :on-click open-share-dialog} (tr "labels.share")]) (when-not (:is-logged permissions) - [:span {:on-click open-login-dialog - :class (stl/css :go-log-btn)} (tr "labels.log-or-sign")])])) + [:> button* {:variant "ghost" + :on-click open-login-dialog} + (tr "labels.log-or-sign")])])) (mf/defc header-sitemap - [{:keys [project file page frame toggle-thumbnails] :as props}] + [{:keys [project file page frame toggle-thumbnails shown-thumbnails] :as props}] (let [project-name (:name project) file-name (:name file) page-name (:name page) @@ -236,11 +245,15 @@ :title (tr "viewer.header.sitemap")} [:span {:class (stl/css :project-name)} project-name] [:div {:class (stl/css :sitemap-text)} - [:div {:class (stl/css :breadcrumb) - :on-click open-dropdown} - [:span {:class (stl/css :breadcrumb-text)} - (dm/str file-name " / " page-name)] - [:span {:class (stl/css :icon)} deprecated-icon/arrow] + [:div {:class (stl/css :breadcrumb)} + [:button {:type "button" + :class (stl/css :breadcrumb-btn) + :aria-expanded (deref show-dropdown?) + :aria-haspopup "menu" + :on-click open-dropdown} + [:span {:class (stl/css :breadcrumb-text)} + (dm/str file-name " / " page-name)] + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :icon)}]] [:span "/"] [:& dropdown {:show @show-dropdown? :on-close close-dropdown} @@ -254,12 +267,14 @@ [:span {:class (stl/css :label)} (get-in file [:data :pages-index id :name])] (when (= page-id id) - [:span {:class (stl/css :icon-check)} deprecated-icon/tick])])]]] - [:div {:class (stl/css :current-frame) - :id "current-frame" - :on-click toggle-thumbnails} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon-check)}])])]]] + [:button {:type "button" + :class (stl/css :current-frame) + :id "current-frame" + :aria-expanded (boolean shown-thumbnails) + :on-click toggle-thumbnails} [:span {:class (stl/css :frame-name)} frame-name] - [:span {:class (stl/css :icon)} deprecated-icon/arrow]]]])) + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :icon)}]]]])) (def ^:private penpot-logo-icon (deprecated-icon/icon-xref :penpot-logo-icon (stl/css :logo-icon))) @@ -322,33 +337,34 @@ :page page :frame frame :toggle-thumbnails toggle-thumbnails + :shown-thumbnails shown-thumbnails :index index}]] [:div {:class (stl/css :mode-zone)} - [:button {:on-click navigate - :data-value "interactions" - :class (stl/css-case :mode-zone-btn true - :selected (= section :interactions)) - :title (tr "viewer.header.interactions-section" (sc/get-tooltip :open-interactions))} - deprecated-icon/play] + [:> icon-button* {:variant "ghost" + :on-click navigate + :data-value "interactions" + :aria-pressed (= section :interactions) + :aria-label (tr "viewer.header.interactions-section" (sc/get-tooltip :open-interactions)) + :icon i/play}] (when (or (:in-team permissions) (= (:who-comment permissions) "all")) - [:button {:on-click navigate - :data-value "comments" - :class (stl/css-case :mode-zone-btn true - :selected (= section :comments)) - :title (tr "viewer.header.comments-section" (sc/get-tooltip :open-comments))} - deprecated-icon/comments]) + [:> icon-button* {:variant "ghost" + :on-click navigate + :data-value "comments" + :aria-pressed (= section :comments) + :aria-label (tr "viewer.header.comments-section" (sc/get-tooltip :open-comments)) + :icon i/comments}]) (when (or (:in-team permissions) (and (= (:type permissions) :share-link) (= (:who-inspect permissions) "all"))) - [:button {:on-click go-to-inspect - :class (stl/css-case :mode-zone-btn true - :selected (= section :inspect)) - :title (tr "viewer.header.inspect-section" (sc/get-tooltip :open-inspect))} - deprecated-icon/code])] + [:> icon-button* {:variant "ghost" + :on-click go-to-inspect + :aria-pressed (= section :inspect) + :aria-label (tr "viewer.header.inspect-section" (sc/get-tooltip :open-inspect)) + :icon i/code}])] [:& header-options {:section section :permissions permissions diff --git a/frontend/src/app/main/ui/viewer/header.scss b/frontend/src/app/main/ui/viewer/header.scss index ffe476cbda..aee1220748 100644 --- a/frontend/src/app/main/ui/viewer/header.scss +++ b/frontend/src/app/main/ui/viewer/header.scss @@ -4,22 +4,29 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared menu placeholders, which move to the DS with the +// menus and dropdowns. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; .viewer-header { position: absolute; - top: 0; + inset-block-start: 0; grid-column: 1 / span 1; grid-row: 1 / span 1; display: grid; - grid-template-columns: 1fr deprecated.$s-92 1fr; + grid-template-columns: 1fr auto 1fr; justify-content: space-between; align-items: center; - height: deprecated.$s-48; - width: 100vw; - padding: deprecated.$s-8 deprecated.$s-12; + block-size: $sz-48; + inline-size: 100vw; + padding: var(--sp-s) var(--sp-m); transition: transform 400ms ease 300ms; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); } // FILE NAVIGATION @@ -28,8 +35,8 @@ display: flex; justify-content: flex-start; flex-basis: min-content; - width: 100%; - gap: deprecated.$s-12; + inline-size: 100%; + gap: var(--sp-m); } .home-link { @@ -39,143 +46,137 @@ } .logo-icon { - width: deprecated.$s-28; - height: deprecated.$s-28; - fill: var(--icon-foreground-hover); + inline-size: $sz-28; + block-size: $sz-28; + fill: var(--color-foreground-primary); } .sitemap-zone { - @include deprecated.flex-column; - + display: flex; + flex-direction: column; + gap: var(--sp-xs); position: relative; - width: 100%; + inline-size: 100%; } .project-name { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .sitemap-text { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .breadcrumb { - @include deprecated.body-small-typography; - @include deprecated.flex-row; + @include use-typography("body-small"); - color: var(--title-foreground-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + color: var(--color-foreground-secondary); +} + +// Text and arrow that open the page menu: not a DS button. +.breadcrumb-btn { + @include use-typography("body-small"); + + display: flex; + align-items: center; + gap: var(--sp-xs); + padding: 0; + border: none; + background: none; + color: inherit; cursor: pointer; } .breadcrumb-text { - @include deprecated.text-ellipsis; + @include text-ellipsis; - max-width: 12vw; // This is a fallback - max-width: 12cqw; // This is a unit refered to container + max-inline-size: 12vw; // This is a fallback + max-inline-size: 12cqw; // This is a unit refered to container } -.icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); - } -} - -.dropdown { - position: absolute; +.icon, +.icon-check { + flex-shrink: 0; + color: var(--color-foreground-secondary); } .dropdown-sitemap { @extend %menu-dropdown; - left: 0; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; - padding: deprecated.$s-6; + inset-inline-start: 0; + inset-block-start: px2rem(50); + inline-size: px2rem(272); + padding: px2rem(6); } .dropdown-element { @extend %dropdown-element-base; - .icon-check { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } + &:hover .label { + color: var(--color-foreground-primary); } - &:hover .label { - color: var(--input-foreground-color-active); + // The current page. + &.selected { + outline: none; + border-color: var(--color-background-quaternary); + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); } } +// Board name that toggles the thumbnails: not a DS button. .current-frame { - @include deprecated.body-small-typography; - @include deprecated.flex-row; + @include use-typography("body-small"); + display: flex; + align-items: center; + gap: var(--sp-xs); flex-grow: 1; - color: var(--title-foreground-color-hover); + padding: 0; + border: none; + background: none; + color: var(--color-foreground-primary); cursor: pointer; - .icon svg { - stroke: var(--title-foreground-color-hover); + .icon { + color: var(--color-foreground-primary); } } .frame-name { - @include deprecated.text-ellipsis; + @include text-ellipsis; - max-width: 17vw; // This is a fallback - max-width: 17cqw; // This is a unit refered to container + max-inline-size: 17vw; // This is a fallback + max-inline-size: 17cqw; // This is a unit refered to container } // SECTION BUTTONS .mode-zone { - @include deprecated.flex-row; - - height: 100%; -} - -.mode-zone-btn { - @extend %button-tertiary; - @include deprecated.flex-center; - - height: deprecated.$s-32; - width: deprecated.$s-28; - padding: 0; - - svg { - @extend %button-icon; - } -} - -.selected { - @extend %button-icon-selected; + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: 100%; } // OPTION AREA .options-zone { - @include deprecated.flex-row; - + display: flex; + align-items: center; position: relative; justify-content: flex-end; - gap: deprecated.$s-8; - z-index: deprecated.$z-index-10; + gap: var(--sp-s); + + // TODO: no DS z-index token for 10; it keeps the menus above the + // viewer content. + z-index: 10; } .view-options { @@ -185,92 +186,54 @@ cursor: pointer; } -.fullscreen-btn { - @extend %button-tertiary; - @include deprecated.flex-center; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } -} - .share-btn { - @extend %button-primary; - - height: deprecated.$s-32; - min-width: deprecated.$s-72; - margin-left: deprecated.$s-4; -} - -.edit-btn { - @extend %button-tertiary; - @include deprecated.flex-center; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } -} - -.go-log-btn { - @extend %button-tertiary; - @include deprecated.body-small-typography; - - height: deprecated.$s-32; - padding: 0 deprecated.$s-8; - border-radius: deprecated.$br-8; - color: var(--button-tertiary-foreground-color-rest); + min-inline-size: px2rem(72); + margin-inline-start: var(--sp-xs); } // ZOOM WIDGET -.zoom-widget { - @include deprecated.button-style; - @include deprecated.flex-center; +.zoom-widget-wrapper { + display: flex; +} - height: deprecated.$s-28; - min-width: deprecated.$s-64; - border-radius: deprecated.$br-8; +.zoom-widget { + --zoom-widget-foreground-color: var(--color-foreground-secondary); + + display: flex; + align-items: center; + justify-content: center; + block-size: $sz-28; + min-inline-size: $sz-64; + padding: 0; + border: none; + border-radius: $br-8; + background: none; + cursor: pointer; .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--button-tertiary-foreground-color-rest); - } - - &:hover { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + color: var(--zoom-widget-foreground-color); } + &:hover, &.selected { - .label { - color: var(--button-tertiary-foreground-color-focus); - } + --zoom-widget-foreground-color: var(--color-foreground-primary); } } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); } .basic-zoom-bar { display: flex; justify-content: space-between; - padding: deprecated.$s-6; + padding: px2rem(6); cursor: auto; } @@ -278,49 +241,15 @@ display: flex; } -.zoom-btn { - @extend %button-tertiary; - - height: deprecated.$s-28; - width: deprecated.$s-28; - border-radius: deprecated.$br-8; - - .zoom-icon { - @include deprecated.flex-center; - - width: deprecated.$s-24; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - } - - &:hover { - .zoom-icon svg { - stroke: var(--button-tertiary-foreground-color-hover); - } - } -} - .zoom-text { - @include deprecated.flex-center; - - height: 100%; - min-width: deprecated.$s-64; + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + min-inline-size: $sz-64; padding: 0; - margin: 0 deprecated.$s-2; - color: var(--modal-title-foreground-color); -} - -.reset-btn { - @extend %button-tertiary; - - color: var(--button-tertiary-foreground-color-hover); - height: deprecated.$s-28; - border-radius: deprecated.$br-8; + margin: 0 px2rem(2); + color: var(--color-foreground-primary); } .zoom-option { @@ -335,11 +264,11 @@ } &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); .shortcuts { .shortcut-key { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); } } } @@ -349,14 +278,14 @@ [data-fullscreen="true"] .viewer-header::after { content: " "; position: absolute; - width: 100%; - height: deprecated.$s-48; - left: 0; - top: deprecated.$s-48; + inline-size: 100%; + block-size: $sz-48; + inset-inline-start: 0; + inset-block-start: $sz-48; } [data-fullscreen="true"] .viewer-header { - transform: translateY(-#{deprecated.$s-48}); + transform: translateY(-#{$sz-48}); } [data-force-visible="true"] .viewer-header, From 576bf4ed3dd5abdbed06eb44c5938d81317ba706 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 26 Oct 2026 14:42:43 +0100 Subject: [PATCH 155/325] :sparkles: Close search and shortcuts panels with Escape (#12374) * :sparkles: Close search and shortcuts panels with Escape The workspace search and shortcuts panels could only be dismissed with the mouse, which interrupts keyboard-first workflows. A new use-escape-to-close hook gives each panel an on-key-down handler that closes it on Escape while the user is working in it: the latest pointer press or focus change must have landed inside the panel, or the panel must hold focus when it opens (a panel that autofocuses its search box, such as Shortcuts, is closed by the first Escape). Focus left behind in a panel after the user moved on to the canvas does not count, so Escape keeps its usual meaning there. The handler never stops the event. The global Escape shortcut still runs and finishes in-progress operations such as path drawing; an earlier version stopped propagation and left a half-drawn path that failed shape validation on the next commit. In the search panel an open filters menu takes the first Escape and the panel closes on the second. The history panel needs no handler: Escape already closes it through the :interrupt it emits. Closes #9540 AI-assisted-by: claude-opus-5-5 AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak * :paperclip: Add e2e test for closing panels with Escape Cover the Escape handling added for the layers search and shortcuts panels with four Playwright cases on the mocked workspace: - Escape closes the layers search panel. - With the search filters menu open, the first Escape closes only the menu and the second closes the panel. - Escape closes the shortcuts panel right after it opens with `?`. The panel autofocuses its search box, so the first Escape must close it instead of only blurring the box. - A path drawn while focus sits in the shortcuts panel is finished by Escape, which proves the panel does not swallow the global shortcut. The first three cases fail on develop. The shortcuts case also fails when the hook does not seed its tracker from the focused element. AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak * :bug: Close History with Escape when opened by shortcut or menu Only the header button opened History through initialize-history, which removes the :document-history flag on the next :interrupt. The Ctrl/Cmd+Alt+H shortcut and the "Version history" menu item toggled the flag directly, so the global Escape left the panel open. Both now emit initialize-history, like the header, and keep their own event origin. Two e2e cases cover the shortcut and the menu; both fail without this change. AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak * :recycle: Apply review suggestions to Escape-to-close Use dom/child? instead of .contains in the Escape hook and drop the redundant nil checks. Drive the path test through clickAt so its clicks wait for the path tool. Add a negative Search case: Escape reaches the panel after the last click landed on the viewport, and the panel stays open. That case fails when the containment check is removed. AI-assisted-by: claude-sonnet-5-5 Signed-off-by: Filip Sajdak --------- Signed-off-by: Filip Sajdak --- .../ui/specs/escape-closes-panels.spec.js | 138 ++++++++++++++++++ .../app/main/data/workspace/shortcuts.cljs | 4 +- frontend/src/app/main/ui/hooks.cljs | 42 ++++++ .../src/app/main/ui/workspace/main_menu.cljs | 5 +- .../app/main/ui/workspace/sidebar/layers.cljs | 21 ++- .../main/ui/workspace/sidebar/shortcuts.cljs | 8 +- 6 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 frontend/playwright/ui/specs/escape-closes-panels.spec.js diff --git a/frontend/playwright/ui/specs/escape-closes-panels.spec.js b/frontend/playwright/ui/specs/escape-closes-panels.spec.js new file mode 100644 index 0000000000..23f86539f8 --- /dev/null +++ b/frontend/playwright/ui/specs/escape-closes-panels.spec.js @@ -0,0 +1,138 @@ +import { test, expect } from "@playwright/test"; +import { WorkspacePage } from "../pages/WorkspacePage"; + +const VIEWPORT_POINTS = [ + [200, 200], + [400, 220], + [320, 420], +]; + +const openSearch = async (page) => { + await page.getByRole("button", { name: "Search" }).click(); + const findInput = page.getByRole("textbox", { name: /^Find/ }); + await expect(findInput).toBeVisible(); + return findInput; +}; + +const openShortcuts = async (page) => { + await page.keyboard.press("?"); + const searchInput = page.getByPlaceholder("Search shortcuts"); + await expect(searchInput).toBeVisible(); + return searchInput; +}; + +const saveVersionButton = (page) => + page.getByRole("button", { name: "Save version" }); + +const openHistoryAssertion = (page) => + expect(saveVersionButton(page)).toBeVisible(); + +test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + "get-file-snapshots?file-id=*", + "workspace/versions-snapshot-1.json", + ); + await workspacePage.goToWorkspace(); +}); + +test("Escape closes the layers search panel", async ({ page }) => { + const findInput = await openSearch(page); + + await page.keyboard.press("Escape"); + + await expect(findInput).toHaveCount(0); +}); + +test("Escape closes the search filters menu before the search panel", async ({ + page, +}) => { + const findInput = await openSearch(page); + await page.getByRole("button", { name: "Filter" }).click(); + const menuItem = page.getByText("Masks", { exact: true }); + await expect(menuItem).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(menuItem).toHaveCount(0); + await expect(findInput).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(findInput).toHaveCount(0); +}); + +test("Escape keeps the search panel open after clicking the viewport", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + const findInput = await openSearch(page); + + await workspacePage.clickAt(...VIEWPORT_POINTS[0]); + await page.keyboard.press("Escape"); + await expect(findInput).toBeVisible(); + + // The click moves focus out of the panel, so a real key press would + // never reach its handler. Dispatch Escape on the input instead, to + // exercise the case the hook exists for: the event reaches the panel + // although the user's last action was on the canvas. + await findInput.dispatchEvent("keydown", { + key: "Escape", + code: "Escape", + keyCode: 27, + bubbles: true, + }); + + await expect(findInput).toBeVisible(); +}); + +test("Escape closes the shortcuts panel right after it opens", async ({ + page, +}) => { + const searchInput = await openShortcuts(page); + + await page.keyboard.press("Escape"); + + await expect(searchInput).toHaveCount(0); +}); + +test("Escape closes History opened with its shortcut", async ({ page }) => { + await page.keyboard.press("ControlOrMeta+Alt+h"); + await openHistoryAssertion(page); + + await page.keyboard.press("Escape"); + + await expect(saveVersionButton(page)).toHaveCount(0); +}); + +test("Escape closes History opened from the main menu", async ({ page }) => { + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("file").last().click(); + await page.locator("#file-menu-show-version-history").click(); + await openHistoryAssertion(page); + + await page.keyboard.press("Escape"); + + await expect(saveVersionButton(page)).toHaveCount(0); +}); + +test("Escape finishes a path drawn with the shortcuts panel open", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + const searchInput = await openShortcuts(page); + await page.getByRole("button", { name: "BASICS" }).click(); + + await page.keyboard.press("p"); + for (const [x, y] of VIEWPORT_POINTS) { + await workspacePage.clickAt(x, y); + } + await page.keyboard.press("Escape"); + + // The shortcuts panel replaces the layers tree, so the new path is + // observed through the design tab of the selected shape. + await expect( + page.getByRole("region", { name: "Stroke section" }), + ).toBeVisible(); + await expect(searchInput).toBeVisible(); +}); diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 17246e5889..b31fdd95d3 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -19,6 +19,7 @@ [app.main.data.workspace.comments :as dwcm] [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.drawing.common :as dwdc] + [app.main.data.workspace.history :as dwh] [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.shape-layout :as dwsl] @@ -655,7 +656,8 @@ :subsections [:panels] :section [:workspace] :fn #(emit-when-no-readonly - (dw/toggle-layout-flag :document-history))} + (-> (dwh/initialize-history) + (vary-meta assoc ::ev/origin "workspace:shortcuts")))} :toggle-colorpalette {:tooltip (ds/alt "P") :label (fn [] (tr "shortcuts.toggle-colorpalette")) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 5bc113bab9..ca3663ada9 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -16,6 +16,7 @@ [app.main.store :as st] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] + [app.util.keyboard :as kbd] [app.util.storage :as storage] [app.util.timers :as ts] [app.util.webapi :as wapi] @@ -597,3 +598,44 @@ (rx/dispose! sub)))) [rowref limit]))) + +(defn use-escape-to-close + "Returns an `on-key-down` handler that closes a panel with Escape. + + The panel only closes while the user is working in it: the latest + pointer press or focus change must have landed inside the element + that owns the handler, or that element must hold focus when it + mounts. Focus left behind in a panel after the user moved on to the + canvas does not count, so Escape keeps its usual meaning there. + + The event is never stopped: the global Escape shortcut still runs + and finishes any in-progress operation. + + `dismiss-nested`, when given, is called first; it returns true when + it consumed the Escape (an open menu, an inline rename), and then + the panel stays open." + ([on-close] + (use-escape-to-close on-close nil)) + ([on-close dismiss-nested] + (let [last-target-ref (mf/use-ref nil)] + (mf/with-effect [] + (let [track (fn [event] + (mf/set-ref-val! last-target-ref (dom/get-target event)))] + ;; A panel that autofocuses its input on open has already + ;; received its `focusin` before this effect runs. + (mf/set-ref-val! last-target-ref (dom/get-active)) + (.addEventListener js/document "pointerdown" track true) + (.addEventListener js/document "focusin" track true) + #(do (.removeEventListener js/document "pointerdown" track true) + (.removeEventListener js/document "focusin" track true)))) + + (mf/use-fn + (mf/deps on-close dismiss-nested) + (fn [event] + (let [panel (dom/get-current-target event) + last-target (mf/ref-val last-target-ref)] + (when (and (kbd/esc? event) + (dom/child? last-target panel) + (not (and (fn? dismiss-nested) (dismiss-nested)))) + (dom/blur! (dom/get-target event)) + (on-close)))))))) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 5a64cf76c9..79d2b80a20 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -23,6 +23,7 @@ [app.main.data.shortcuts :as scd] [app.main.data.workspace :as dw] [app.main.data.workspace.comments :as dwcm] + [app.main.data.workspace.history :as dwh] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.shortcuts :as sc] @@ -617,9 +618,9 @@ on-show-version-history (mf/use-fn - (mf/deps file-id) (fn [_] - (st/emit! (dw/toggle-layout-flag :document-history)))) + (st/emit! (-> (dwh/initialize-history) + (vary-meta assoc ::ev/origin "workspace:menu"))))) on-show-version-history-key-down (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 5525364ee3..f841fbee91 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -386,6 +386,21 @@ (st/emit! dw/close-layers-search) (st/emit! (dw/open-layers-search :find {:force? true})))))) + close-search + (mf/use-fn + #(st/emit! dw/close-layers-search)) + + dismiss-filters-menu + (mf/use-fn + (mf/deps show-menu? hide-menu) + (fn [] + (when ^boolean show-menu? + (hide-menu) + true))) + + on-search-key-down + (hooks/use-escape-to-close close-search dismiss-filters-menu) + remove-filter (mf/use-fn (fn [event] @@ -587,7 +602,8 @@ #(mf/html (if show-search? [:* - [:div {:class (stl/css :tool-window-bar)} + [:div {:class (stl/css :tool-window-bar) + :on-key-down on-search-key-down} [:> search-bar* {:input-ref search-input-ref :class (stl/css :search-item) :on-change update-search-text @@ -612,7 +628,8 @@ :on-click toggle-search :icon i/close}]] - [:div {:class (stl/css :replace-wrapper)} + [:div {:class (stl/css :replace-wrapper) + :on-key-down on-search-key-down} (when ^boolean find-replace-mode? [:div {:class (stl/css :replace-row)} [:> input* {:type "text" diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index 016b9b8ba5..4d262f12be 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -23,6 +23,7 @@ [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.product.panel-title :refer [panel-title*]] + [app.main.ui.hooks :as hooks] [app.main.ui.shortcuts :as ss] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -60,7 +61,9 @@ filter-term* (mf/use-state "") filter-term (deref filter-term*) - close-fn #(st/emit! (dw/toggle-layout-flag :shortcuts)) + close-fn (mf/use-fn #(st/emit! (dw/toggle-layout-flag :shortcuts))) + + on-key-down (hooks/use-escape-to-close close-fn) {:keys [all-shortcuts all-sc-names all-sub-names all-section-names]} (ss/build-all-shortcuts workspace-shortcuts dashboard-shortcuts viewer-shortcuts) @@ -127,7 +130,8 @@ (mf/use-fn #(st/emit! (rt/nav :settings-shortcuts {} {::rt/new-window true})))] - [:div {:class (dm/str class " " (stl/css :shortcuts))} + [:div {:class (dm/str class " " (stl/css :shortcuts)) + :on-key-down on-key-down} [:> panel-title* {:class (stl/css :shortcuts-title) :text (tr "shortcuts.title") :on-close close-fn}] From f2e5b7e20ec74c9c42a23da5c8b2742b66a31f61 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 26 Oct 2026 15:24:18 +0100 Subject: [PATCH 156/325] :bug: Fix swapped nested component loses absolute position after a lib update (#12556) --- common/src/app/common/logic/libraries.cljc | 15 ++- .../logic/comp_flex_interactions_test.cljc | 117 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 5d003a64b2..ac5a516156 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2379,6 +2379,15 @@ (assoc attr (get origin attr))))) dest)))) +(defn- get-flex-child-main + "The near-main child that owns this copy child's flex-item attrs. + Non-swapped copies match by shape-ref; swapped copies match by swap slot + (the original occupant of the parent layout slot)." + [main-container main-component child-copy] + (or (ctf/get-ref-shape main-container main-component child-copy) + (some->> (ctk/get-swap-slot child-copy) + (ctf/get-component-shape main-container main-component)))) + (defn- update-flex-child-copy-attrs "Synchronizes the attributes inside the flex-child items (main->copy)" [changes shape-main shape-copy main-container main-component copy-container omit-touched?] @@ -2404,9 +2413,9 @@ (pcb/update-shapes (:shapes shape-copy) (fn [child-copy] - (let [child-main (ctf/get-ref-shape main-container main-component child-copy)] - (-> child-copy - (propagate-attrs child-main flex-child-attrs omit-touched?)))) + (if-let [child-main (get-flex-child-main main-container main-component child-copy)] + (propagate-attrs child-copy child-main flex-child-attrs omit-touched?) + child-copy)) {:ignore-touched true}))] (pcb/concat-changes changes new-changes))) diff --git a/common/test/common_tests/logic/comp_flex_interactions_test.cljc b/common/test/common_tests/logic/comp_flex_interactions_test.cljc index 5ded09e9a3..0fa3644c6f 100644 --- a/common/test/common_tests/logic/comp_flex_interactions_test.cljc +++ b/common/test/common_tests/logic/comp_flex_interactions_test.cljc @@ -17,6 +17,7 @@ [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [clojure.test :as t])) @@ -153,3 +154,119 @@ ;; ==== Check (t/is (mth/close? (inc copy-x) (:x (tho/bottom-shape file' :copy2-root)))) (t/is (= [(thi/id :copy-popup)] (destinations copy-nested-head'))))) + +(defn- swap-to-library-component + "Swap a shape in `file` to a component that lives in `library`, keeping + flex-item attrs the way production `component-swap` does." + [file library shape-label new-component-label] + (let [shape (ths/get-shape file shape-label) + page (thf/current-page file) + keep-props-values (select-keys shape ctk/swap-keep-attrs) + libraries {(:id library) library + (:id file) file} + id-new (:id (thc/get-component library new-component-label)) + [_ _ changes] (cll/generate-component-swap + (pcb/empty-changes nil (:id page)) + (:objects page) + shape + (:data library) + page + libraries + id-new + 0 + nil + keep-props-values + false)] + (thf/apply-changes file changes))) + +(defn- setup-library-with-absolute-nested-icon + "Library: icon A, icon B, and a flex board whose nested child is an + instance of icon A with absolute position." + [] + (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (tho/add-frame :board-root {:name "Board" + :layout :flex + :layout-flex-dir :row}) + (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) + (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (thc/make-component :board :board-root))) + +(t/deftest test-sync-library-keeps-swapped-nested-absolute + (let [;; ==== Setup + library (setup-library-with-absolute-nested-icon) + file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-nested-head])) + file (swap-to-library-component file library :copy-nested-head :icon-b) + swapped (ths/get-shape file :copy-nested-head) + + ;; ==== Action + library' (update-shape library :board-root + #(assoc % :fills (ths/sample-fills-color :fill-color "#fabada"))) + file' (thf/apply-changes file (sync-file-from-library file library')) + swapped' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (true? (:layout-item-absolute swapped))) + (t/is (some? (ctk/get-swap-slot swapped))) + (t/is (= (thi/id :icon-b) (:component-id swapped))) + (t/is (some? (ctk/get-swap-slot swapped'))) + (t/is (= (thi/id :icon-b) (:component-id swapped'))) + (t/is (true? (:layout-item-absolute swapped'))))) + +(t/deftest test-sync-library-propagates-slot-absolute-to-swapped-nested + (let [;; ==== Setup + library (setup-library-with-absolute-nested-icon) + file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-nested-head])) + file (swap-to-library-component file library :copy-nested-head :icon-b) + + ;; ==== Action + library' (update-shape library :nested-head + #(assoc % :layout-item-absolute false)) + file' (thf/apply-changes file (sync-file-from-library file library')) + swapped' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (some? (ctk/get-swap-slot swapped'))) + (t/is (= (thi/id :icon-b) (:component-id swapped'))) + (t/is (not (true? (:layout-item-absolute swapped')))))) + +(t/deftest test-sync-library-flex-child-attrs-to-unswapped-sibling + (let [;; ==== Setup + margin {:m1 5 :m2 5 :m3 5 :m4 5} + library (-> (thf/sample-file :library) + (tho/add-simple-component :icon-a :icon-a-root :icon-a-child) + (tho/add-simple-component :icon-b :icon-b-root :icon-b-child) + (tho/add-frame :board-root {:name "Board" + :layout :flex + :layout-flex-dir :row}) + (thc/instantiate-component :icon-a :nested-head {:parent-label :board-root}) + (ths/add-sample-shape :sibling :type :rect :name "Sibling" :parent-label :board-root) + (update-shape :nested-head #(assoc % :layout-item-absolute true)) + (thc/make-component :board :board-root)) + file (-> (thf/sample-file :file) + (thc/instantiate-component :board + :copy-board + :library library + :children-labels [:copy-nested-head :copy-sibling])) + file (swap-to-library-component file library :copy-nested-head :icon-b) + + ;; ==== Action + library' (update-shape library :sibling + #(assoc % :layout-item-margin margin)) + file' (thf/apply-changes file (sync-file-from-library file library')) + sibling' (ths/get-shape file' :copy-sibling) + swapped' (ths/get-shape file' :copy-nested-head)] + + ;; ==== Check + (t/is (= margin (:layout-item-margin sibling'))) + (t/is (true? (:layout-item-absolute swapped'))) + (t/is (some? (ctk/get-swap-slot swapped'))))) From 376ecda94ec6398be163b45048b3d1609b41d07a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 15:53:05 +0100 Subject: [PATCH 157/325] :recycle: Move viewport top bar styles off the deprecated styles (#12488) * :recycle: Move viewport top bar styles off the deprecated styles The viewport top bars (view only bar of the inspect tab, path edition bar) already use DS buttons. Move top_bar.scss to DS sizes, borders, spacing tokens and logical properties, resolve the panel and shadow color aliases, and drop the refactor import. The unused done-btn class, the last user of the deprecated button placeholders here, goes away. The two z-index values have no DS token, so they stay with a TODO. Add a visual test of the view only bar; it looks the same as before, pixel for pixel. AI-assisted-by: claude-opus-5-5 * :recycle: Use the body-small typography in the viewport top bar title The title only set a 12px font size, which body-small already has. Use the DS typography instead of the inline size and its TODO. The view only bar looks the same, pixel for pixel. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 22 ++++++++ .../main/ui/workspace/viewport/top_bar.scss | 55 +++++++++---------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 498663ae09..d5621b77ee 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -629,6 +629,28 @@ test.describe("Guides", () => { }); }); +test.describe("Viewport top bar", () => { + test("Shows the view only bar of the inspect tab", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickLeafLayer("Board"); + await workspace.rightSidebar.getByRole("tab", { name: "Inspect" }).click(); + const bar = page.getByText("Inspecting code").locator("../../.."); + await expect(bar).toBeVisible(); + await page.mouse.move(640, 600); + await expect(bar).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Left header", () => { test("Shows the shared badge and the save status", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 255ae85080..07eb526fa0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -4,55 +4,54 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/_utils.scss" as *; .viewport-actions, .viewport-actions-path { pointer-events: none; position: absolute; - --actions-toolbar-position-y: #{deprecated.$s-28}; - --actions-toolbar-offset-y: #{deprecated.$s-6}; + --actions-toolbar-position-y: #{$sz-28}; + --actions-toolbar-offset-y: #{px2rem(6)}; - top: calc(var(--actions-toolbar-position-y) + var(--actions-toolbar-offset-y)); - left: 50%; + inset-block-start: calc(var(--actions-toolbar-position-y) + var(--actions-toolbar-offset-y)); + inset-inline-start: 50%; transform: translateX(-50%); - z-index: deprecated.$z-index-20; + + // TODO: No DS z-index token for this value. + z-index: 20; } .viewport-actions-path { - z-index: deprecated.$z-index-3; + // TODO: No DS z-index token for this value. + z-index: 3; } .viewport-actions-container { - @include deprecated.flex-row; - - background: var(--panel-background-color); - border-radius: deprecated.$br-12; - box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); - gap: deprecated.$s-8; - height: deprecated.$s-48; - padding: deprecated.$s-8; + display: flex; + align-items: center; + gap: var(--sp-s); + background: var(--color-background-primary); + border-radius: $br-12; + box-shadow: 0 0 px2rem(12) 0 var(--color-shadow-dark); + block-size: $sz-48; + padding: var(--sp-s); cursor: initial; pointer-events: initial; - min-width: deprecated.$s-400; - border: deprecated.$s-2 solid var(--panel-border-color); + min-inline-size: $sz-400; + border: $b-2 solid var(--color-background-quaternary); } .viewport-actions-title { + @include use-typography("body-small"); + flex: 1; - font-size: deprecated.$fs-12; color: var(--color-foreground-secondary); - padding-left: deprecated.$s-8; - width: max-content; -} - -.done-btn { - @extend %button-primary; - - text-transform: uppercase; - padding: deprecated.$s-8 deprecated.$s-20; - font-size: deprecated.$fs-11; + padding-inline-start: var(--sp-s); + inline-size: max-content; } .viewport-actions-no-rulers { From 96b62f6475697b9fe7919c46ed17b353ed52598b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 17:17:46 +0100 Subject: [PATCH 158/325] :recycle: Use DS buttons in the delete account modal (#12540) * :recycle: Use DS buttons in the delete account modal The close button of the delete account modal becomes a ghost DS icon button named "Close"; the class that places it goes on the tooltip wrapper. The cancel and delete buttons become DS secondary and destructive buttons, so their labels no longer wrap and the delete button takes the DS destructive red. The delete button keeps its test id. A visual test covers the modal at rest and with the close and delete buttons hovered. AI-assisted-by: claude-opus-5-5 * :recycle: Move delete account modal styles closer to the DS The delete account stylesheet resolves its legacy sizes, spacing, mixins and color aliases to DS tokens and uses logical properties and the DS typography module without a namespace. The legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px), so the title and the content below sit 1px lower. The organization counts used a fixed dark-theme gray; they now use the themed DS secondary foreground. Two unused selectors are commented out with a TODO. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the delete account modal The SCSS refactor of the delete account modal left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .fields-row, .select-title. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 20 +++++++ .../app/main/ui/settings/delete_account.cljs | 23 ++++--- .../app/main/ui/settings/delete_account.scss | 60 +++++++------------ 3 files changed, 56 insertions(+), 47 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 3b783793f6..1b2ce0d483 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -317,3 +317,23 @@ test("User goes to the team settings section", async ({ page }) => { await expect(page.getByText("TEAM INFO")).toBeVisible(); await expect(dashboardPage.page).toHaveScreenshot(); }); + +test.describe("Delete account modal", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Delete account modal buttons", async ({ page }) => { + await page.goto("/?screen=settings-profile"); + await page.getByText("Want to remove your account?").click(); + + const modal = page.locator("[class*='modal-container']"); + await expect(page.getByTestId("delete-account-btn")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await modal.locator("[class*='modal-header'] button").hover(); + await expect(modal).toHaveScreenshot(shot); + + await page.getByTestId("delete-account-btn").hover(); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index 678ff29f20..b916734729 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -14,8 +14,9 @@ [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.organization-avatar :refer [organization-avatar*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -62,8 +63,11 @@ [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} (tr "modals.delete-account.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :warning-notice)} @@ -100,11 +104,12 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:button {:class (stl/css :cancel-button) - :on-click modal/hide!} + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} (tr "modals.delete-account.cancel")] - [:button {:class (stl/css-case :accept-button true - :danger true) - :on-click on-accept - :data-testid "delete-account-btn"} + [:> button* {:variant "destructive" + :type "button" + :on-click on-accept + :data-testid "delete-account-btn"} (tr "modals.delete-account.confirm")]]]]])) diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index 4d0c1efafc..f27a48c7a4 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @@ -19,54 +21,48 @@ display: flex; flex-direction: column; - min-width: deprecated.$s-408; + min-inline-size: px2rem(408); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.flex-column; - @include deprecated.body-small-typography; + @include use-typography("body-small"); + display: flex; + flex-direction: column; flex: 1 1 auto; - min-height: 0; - gap: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + min-block-size: 0; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .warning-notice { flex-shrink: 0; } -.fields-row { - @include deprecated.flex-column; -} - -.select-title { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); -} - .organizations-section { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-direction: column; flex: 1 1 auto; - min-height: 0; + min-block-size: 0; gap: var(--sp-l); } @@ -98,7 +94,7 @@ display: flex; flex-direction: column; flex: 1 1 auto; - min-height: 0; + min-block-size: 0; gap: var(--sp-l); margin: 0; padding: 0; @@ -119,28 +115,16 @@ } .organization-name { - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .organization-counts { display: flex; flex-direction: row; gap: var(--sp-s); - color: var(--df-secondary); + color: var(--color-foreground-secondary); } .action-buttons { @extend %modal-action-btns; } - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-button { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} From f07452e36bc8cf6e4a4cb2dff4f82ecb4851ff9a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 26 Oct 2026 17:55:54 +0100 Subject: [PATCH 159/325] :recycle: Use DS buttons and icons in the inspect code tab (#12521) * :recycle: Use DS buttons and icons in the inspect code tab "Copy all code" becomes a DS secondary button, and the expand panel buttons ghost DS icon buttons labelled "Expand panel" (new key, en and es). The collapse toggles of the CSS and markup blocks stay raw buttons, since they sit in a 12px column; their arrow uses DS icons and they get a name and aria-expanded. The copy buttons belong to the shared copy button and move with it. A visual test covers the code tab buttons, expanded and collapsed. AI-assisted-by: claude-opus-5-5 * :recycle: Move inspect code tab styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties. The 11px uppercase titles become the 12px DS headline-small, the only difference. The code typography keeps its values with a TODO: the DS code-font names "Roboto Mono", while the app loads "robotomono". The full-width "Copy all code" DS button centers its label explicitly. The refactor/ import stays only for the copy buttons, styled here until the shared copy button moves to the DS. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 40 ++++++ frontend/src/app/main/ui/inspect/code.cljs | 42 +++--- frontend/src/app/main/ui/inspect/code.scss | 132 ++++++++---------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 5 files changed, 133 insertions(+), 89 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 432b3df9c6..2085eca3e3 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -322,3 +322,43 @@ test.describe("Viewer header", () => { }); }); }); + +test.describe("Inspect code tab", () => { + test("Shows the code tab buttons, expanded and collapsed", async ({ + page, + }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + await viewerPage.showCode(); + await page.getByRole("combobox").filter({ hasText: "Styles" }).click(); + await page.getByRole("option", { name: "Code" }).click(); + + const copyAll = page.getByRole("button", { name: "Copy all code" }); + await expect(copyAll).toBeVisible(); + // The CSS block header, with its toggle, expand and copy buttons. + const cssToggle = page.locator('button[data-type="css"]'); + const cssHeader = cssToggle.locator(".."); + await page.mouse.move(0, 0); + await expect(copyAll).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + await expect(cssHeader).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await cssToggle.click(); + await page.mouse.move(0, 0); + // Collapsed: the arrow turns. + await expect(cssHeader).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 78b748a064..b96759932a 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -20,14 +20,17 @@ [app.main.ui.components.code-block :refer [code-block*]] [app.main.ui.components.copy-button :refer [copy-button*]] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks.resize :refer [use-resize-hook]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.shapes.text.fontfaces :refer [shapes->fonts]] [app.util.clipboard :as clipboard] [app.util.code-beautify :as cb] [app.util.code-gen :as cg] [app.util.dom :as dom] [app.util.http :as http] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -278,8 +281,9 @@ [:div {:class (stl/css-case :element-options true :viewer-code-block (= :viewer from))} [:div {:class (stl/css :attributes-block)} - [:button {:class (stl/css :download-button) - :on-click handle-copy-all-code} + [:> button* {:variant "secondary" + :class (stl/css :download-button) + :on-click handle-copy-all-code} "Copy all code"]] #_[:div.attributes-block @@ -291,11 +295,12 @@ [:div {:class (stl/css :code-row-lang)} [:button {:class (stl/css :toggle-btn) :data-type "css" + :aria-label "CSS" + :aria-expanded (not collapsed-css?) :on-click handle-collapse} - [:span {:class (stl/css-case - :collapsabled-icon true - :rotated collapsed-css?)} - deprecated-icon/arrow]] + [:> icon* {:icon-id (if collapsed-css? i/arrow-right i/arrow-down) + :size "s" + :class (stl/css :collapsabled-icon)}]] [:div {:class (stl/css :code-lang-option)} "CSS"] @@ -306,9 +311,10 @@ ;; :options [{:label "CSS" :value "css"}]}] [:div {:class (stl/css :action-btns)} - [:button {:class (stl/css :expand-button) - :on-click on-expand} - deprecated-icon/code] + [:> icon-button* {:variant "ghost" + :aria-label (tr "inspect.code.expand-panel") + :icon i/code + :on-click on-expand}] [:> copy-button* {:data copy-css-fn :class (stl/css :css-copy-btn) @@ -330,11 +336,12 @@ [:div {:class (stl/css :code-row-lang)} [:button {:class (stl/css :toggle-btn) :data-type "markup" + :aria-label (str/upper markup-type) + :aria-expanded (not collapsed-markup?) :on-click handle-collapse} - [:span {:class (stl/css-case - :collapsabled-icon true - :rotated collapsed-markup?)} - deprecated-icon/arrow]] + [:> icon* {:icon-id (if collapsed-markup? i/arrow-right i/arrow-down) + :size "s" + :class (stl/css :collapsabled-icon)}]] [:& radio-buttons {:selected markup-type :on-change set-markup @@ -347,9 +354,10 @@ :id :svg}]] [:div {:class (stl/css :action-btns)} - [:button {:class (stl/css :expand-button) - :on-click on-expand} - deprecated-icon/code] + [:> icon-button* {:variant "ghost" + :aria-label (tr "inspect.code.expand-panel") + :icon i/code + :on-click on-expand}] [:> copy-button* {:data copy-html-fn :class (stl/css :html-copy-btn) diff --git a/frontend/src/app/main/ui/inspect/code.scss b/frontend/src/app/main/ui/inspect/code.scss index 58acf7b110..01367318d6 100644 --- a/frontend/src/app/main/ui/inspect/code.scss +++ b/frontend/src/app/main/ui/inspect/code.scss @@ -4,45 +4,55 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the copy buttons, styled here until the shared copy button +// moves to the DS. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .element-options { display: flex; flex-direction: column; - height: calc(100vh - #{deprecated.$s-160}); // TODO: Fix this hardcoded value + block-size: calc(100vh - #{$sz-160}); // TODO: Fix this hardcoded value overflow: hidden; - padding-bottom: deprecated.$s-16; + padding-block-end: var(--sp-l); overflow-y: auto; padding-inline: var(--sp-m); } .viewer-code-block { - height: calc(100vh - #{deprecated.$s-108}); // TODO: Fix this hardcoded value + block-size: calc(100vh - #{px2rem(108)}); // TODO: Fix this hardcoded value } +// A full-width DS button does not center its label on its own. .download-button { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - height: deprecated.$s-32; - width: 100%; - margin: deprecated.$s-8 0; + justify-content: center; + inline-size: 100%; + margin: var(--sp-s) 0; } .code-block { - @include deprecated.code-typography; - + // TODO: No exact DS typography match found: the DS code-font names + // "Roboto Mono", the app loads it as "robotomono". + // Closest match: code-font. + font-family: "robotomono", monospace; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.2; display: flex; flex-direction: column; - height: 100%; - min-height: 0; - padding: 0 deprecated.$s-4 deprecated.$s-8 0; + block-size: 100%; + min-block-size: 0; + padding: 0 var(--sp-xs) var(--sp-s) 0; + // The highlighted code markup has no class of its own. pre { - border-radius: deprecated.$br-8; - padding: deprecated.$s-16; + border-radius: $br-8; + padding: var(--sp-l); overflow: auto; - height: 100%; + block-size: 100%; } // Overrides background setted in the theme @@ -51,19 +61,19 @@ } &.collapsed { - height: initial; + block-size: initial; } } .code-row-lang { display: grid; - grid-template-columns: deprecated.$s-12 1fr deprecated.$s-60; - gap: deprecated.$s-4; - width: 100%; + grid-template-columns: $sz-12 1fr auto; + gap: var(--sp-xs); + inline-size: 100%; } .code-lang { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); display: flex; align-items: center; @@ -71,92 +81,70 @@ .action-btns { display: grid; - grid-template-columns: 1fr 1fr; - gap: deprecated.$s-4; + grid-template-columns: auto auto; + gap: var(--sp-xs); } -.expand-button, .css-copy-btn, .html-copy-btn { @extend %button-tertiary; - height: deprecated.$s-32; - width: deprecated.$s-28; + block-size: $sz-32; + inline-size: $sz-28; svg { @extend %button-icon; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-secondary); } } .code-lang-options { - max-width: deprecated.$s-108; + max-inline-size: px2rem(108); } .code-lang-select { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - width: deprecated.$s-72; - border: deprecated.$s-1 solid transparent; + inline-size: px2rem(72); + border: $b-1 solid transparent; background-color: transparent; - color: var(--menu-foreground-color-disabled); + color: var(--color-foreground-secondary); } .code-lang-option { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - width: deprecated.$s-72; - height: deprecated.$s-32; - padding: deprecated.$s-8; - color: var(--menu-foreground-color-disabled); + inline-size: px2rem(72); + block-size: $sz-32; + padding: var(--sp-s); + color: var(--color-foreground-secondary); } .code-row-display { flex: 1; - min-height: 0; + min-block-size: 0; overflow: hidden; - padding-bottom: deprecated.$s-8; + padding-block-end: var(--sp-s); } +// A disclosure toggle in a 12px column, not a DS button. .toggle-btn { - @include deprecated.button-style; + --toggle-btn-foreground-color: var(--color-foreground-secondary); display: flex; align-items: center; padding: 0; - color: var(--title-foreground-color); - stroke: var(--title-foreground-color); - - .collapsabled-icon { - @include deprecated.flex-center; - - height: deprecated.$s-24; - border-radius: deprecated.$br-8; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); - } - - &.rotated svg { - transform: rotate(0deg); - } - } + border: none; + background: none; + cursor: pointer; + color: var(--toggle-btn-foreground-color); &:hover { - color: var(--title-foreground-color-hover); - stroke: var(--title-foreground-color-hover); - - .title { - color: var(--title-foreground-color-hover); - stroke: var(--title-foreground-color-hover); - } - - .collapsabled-icon svg { - stroke: var(--title-foreground-color-hover); - } + --toggle-btn-foreground-color: var(--color-foreground-primary); } } + +.collapsabled-icon { + flex-shrink: 0; +} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index dde95de91f..c747726caa 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2281,6 +2281,10 @@ msgstr "Variant properties" msgid "inspect.attributes.variants" msgstr "Variants properties" +#: src/app/main/ui/inspect/code.cljs:315, src/app/main/ui/inspect/code.cljs:358 +msgid "inspect.code.expand-panel" +msgstr "Expand panel" + #: src/app/main/ui/inspect/right_sidebar.cljs:169 msgid "inspect.color-space-label" msgstr "Select color space" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1bd162954c..c95dafcebd 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2317,6 +2317,10 @@ msgstr "Propiedades de la variante" msgid "inspect.attributes.variants" msgstr "Propiedades de las variantes" +#: src/app/main/ui/inspect/code.cljs:315, src/app/main/ui/inspect/code.cljs:358 +msgid "inspect.code.expand-panel" +msgstr "Expandir panel" + #: src/app/main/ui/inspect/right_sidebar.cljs:169 msgid "inspect.color-space-label" msgstr "Seleccionar espacio de color" From cf19d7f8708f637860d27b131f5275c8948942ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 26 Oct 2026 19:26:43 +0100 Subject: [PATCH 160/325] :construction_worker: Include the version in the bundle and image build key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle stamps the version into version.txt, so a commit that gets a new tag (2.18.1-RC1 -> 2.18.1) needs a new bundle and image set. Keying them by commit sha alone reused the RC artifacts for the final release. Signed-off-by: David Barragán Merino --- .github/workflows/build-bundle.yml | 32 ++++++++++------- .github/workflows/build-docker.yml | 53 ++++++++++++++++------------ .github/workflows/build-renovate.yml | 22 +++++++----- 3 files changed, 62 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 88c04a2634..eb4b335313 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -61,6 +61,7 @@ jobs: outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} + build_key: ${{ steps.vars.outputs.build_key }} short_sha: ${{ steps.vars.outputs.short_sha }} sha: ${{ steps.vars.outputs.sha }} commit_title: ${{ steps.vars.outputs.commit_title }} @@ -79,16 +80,22 @@ jobs: env: GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | + # Same command the build uses to stamp version.txt, so the key + # always matches what ends up inside the bundle. + BUNDLE_VERSION=$(./manage.sh version) + SHORT_SHA=$(git rev-parse --short=12 HEAD) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT - echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT - echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT + echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT - # Keyed by commit sha, same as build-docker.yml's marker check, so - # both pipelines agree on what "already built" means: any ref that - # points at an already-bundled commit skips the build, regardless of - # which ref built it first. `force` bypasses this check entirely. + # Keyed by commit sha AND version, same as build-docker.yml's marker + # check, so both pipelines agree on what "already built" means. The + # version is stamped into the bundle, so a new tag on an already + # bundled commit (2.18.1-RC1 -> 2.18.1) must produce a new bundle. + # `force` bypasses this check entirely. - name: Check if this bundle is already built id: check env: @@ -97,8 +104,7 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} FORCE: ${{ inputs.force }} - SHORT_SHA: ${{ steps.vars.outputs.short_sha }} - BUNDLE_VERSION: ${{ steps.vars.outputs.bundle_version }} + BUILD_KEY: ${{ steps.vars.outputs.build_key }} run: | if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT @@ -112,7 +118,7 @@ jobs: BUNDLE_EXISTS=$(aws s3api head-object \ --bucket "$S3_BUCKET" \ - --key "penpot-sha-${SHORT_SHA}.zip" \ + --key "penpot-${BUILD_KEY}.zip" \ > /dev/null 2>&1 && echo "true" || echo "false") if [ "$BUNDLE_EXISTS" = "true" ]; then @@ -120,7 +126,7 @@ jobs: { echo "### ⏭️ Bundle build skipped" echo "" - echo "The bundle in S3 was already built from \`sha-${SHORT_SHA}\` (\`${BUNDLE_VERSION}\`)." + echo "The bundle in S3 was already built for \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -164,11 +170,11 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.check.outputs.short_sha }} + BUILD_KEY: ${{ needs.check.outputs.build_key }} BUNDLE_VERSION: ${{ needs.check.outputs.bundle_version }} run: | aws s3 cp zips/penpot.zip \ - "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" \ + "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" \ --metadata "bundle-version=${BUNDLE_VERSION}" - name: Write step summary @@ -180,7 +186,7 @@ jobs: { echo "### ✅ Bundle built" echo "" - echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Version: \`${BUNDLE_VERSION}\` (\`./manage.sh version\`)" echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index a8d6a1976d..9c674eba33 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -85,6 +85,7 @@ jobs: bundle_version: ${{ steps.vars.outputs.bundle_version }} sha: ${{ steps.vars.outputs.sha }} short_sha: ${{ steps.vars.outputs.short_sha }} + build_key: ${{ steps.vars.outputs.build_key }} commit_title: ${{ steps.vars.outputs.commit_title }} exists: ${{ steps.check.outputs.exists }} @@ -105,10 +106,14 @@ jobs: REF_SLUG=$(printf '%s' "$GH_REF" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-100) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "ref_slug=$REF_SLUG" >> $GITHUB_OUTPUT + # Must match the key computed by build-bundle.yml. + BUNDLE_VERSION=$(./manage.sh version) + SHORT_SHA=$(git rev-parse --short=12 HEAD) echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT - echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT + echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every @@ -123,14 +128,14 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} FORCE: ${{ inputs.force }} - SHORT_SHA: ${{ steps.vars.outputs.short_sha }} + BUILD_KEY: ${{ steps.vars.outputs.build_key }} run: | if [ "$FORCE" = "true" ]; then echo "exists=false" >> $GITHUB_OUTPUT mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + ZIP="$BUNDLE_CACHE/penpot-${BUILD_KEY}.zip" + aws s3 cp "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" { echo "### 🔁 Image set build forced" @@ -142,13 +147,13 @@ jobs: if aws s3api head-object \ --bucket "$S3_BUCKET" \ - --key "markers/images-sha-${SHORT_SHA}" \ + --key "markers/images-${BUILD_KEY}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`sha-${SHORT_SHA}\`." + echo "The whole set was already built and promoted for \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -158,9 +163,9 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + ZIP="$BUNDLE_CACHE/penpot-${BUILD_KEY}.zip" if [ ! -f "$ZIP" ]; then - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi fi @@ -231,14 +236,14 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} IMAGE: ${{ matrix.image }} run: | - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + ZIP="$BUNDLE_CACHE/penpot-${BUILD_KEY}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/penpot-${BUILD_KEY}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi # Extract only the bundle this job needs. @@ -274,7 +279,7 @@ jobs: sbom: true # Immutable tag only; branch tags are moved atomically for the # whole image set by the `promote` job. - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.short_sha }} + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:${{ needs.prepare.outputs.build_key }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max @@ -305,13 +310,13 @@ jobs: env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ - "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" + "${DOCKER_REGISTRY}/${image}:${BUILD_KEY}" done # The marker is written LAST: its presence certifies that all five @@ -322,26 +327,27 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} run: | echo "$GITHUB_RUN_ID" | aws s3 cp - \ - "s3://${S3_BUCKET}/markers/images-sha-${SHORT_SHA}" + "s3://${S3_BUCKET}/markers/images-${BUILD_KEY}" - name: Write step summary env: REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set promoted" echo "" - echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Version: \`${BUNDLE_VERSION}\` (\`./manage.sh version\`)" echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 3b. Skip path: make sure THIS ref's tags point to the existing @@ -374,30 +380,31 @@ jobs: env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ - "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" + "${DOCKER_REGISTRY}/${image}:${BUILD_KEY}" done - name: Write step summary env: REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUILD_KEY: ${{ needs.prepare.outputs.build_key }} BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} run: | { echo "### ✅ Image set already built (branch tags ensured)" echo "" - echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)" + echo "- Version: \`${BUNDLE_VERSION}\` (\`./manage.sh version\`)" echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Checked at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`${BUILD_KEY}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── diff --git a/.github/workflows/build-renovate.yml b/.github/workflows/build-renovate.yml index 6156aed086..09f8a51aa0 100644 --- a/.github/workflows/build-renovate.yml +++ b/.github/workflows/build-renovate.yml @@ -27,7 +27,7 @@ jobs: permissions: contents: read outputs: - short_sha: ${{ steps.bundle.outputs.short_sha }} + bundle_key: ${{ steps.bundle.outputs.bundle_key }} images: ${{ steps.changes.outputs.images }} devenv: ${{ steps.changes.outputs.devenv }} imagemagick: ${{ steps.changes.outputs.imagemagick }} @@ -60,7 +60,9 @@ jobs: } >> "$GITHUB_OUTPUT" # Renovate only changes Dockerfiles and package manager versions, so - # the most recent develop bundle is enough to build the images. + # the most recent develop bundle is enough to build the images. The + # version part of the key does not matter here: take any bundle of + # the commit. - name: Find the latest develop bundle id: bundle env: @@ -72,10 +74,12 @@ jobs: run: | for SHA in $(git rev-list --first-parent --max-count=50 "$BASE_SHA"); do SHORT_SHA=$(git rev-parse --short=12 "$SHA") - if aws s3api head-object --bucket "$S3_BUCKET" \ - --key "penpot-sha-${SHORT_SHA}.zip" > /dev/null 2>&1; then - echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - echo "Using bundle \`penpot-sha-${SHORT_SHA}.zip\`" >> "$GITHUB_STEP_SUMMARY" + BUNDLE_KEY=$(aws s3api list-objects-v2 --bucket "$S3_BUCKET" \ + --prefix "penpot-sha-${SHORT_SHA}-" \ + --query 'Contents[0].Key' --output text) + if [ -n "$BUNDLE_KEY" ] && [ "$BUNDLE_KEY" != "None" ]; then + echo "bundle_key=$BUNDLE_KEY" >> "$GITHUB_OUTPUT" + echo "Using bundle \`${BUNDLE_KEY}\`" >> "$GITHUB_STEP_SUMMARY" exit 0 fi done @@ -135,13 +139,13 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} S3_BUCKET: ${{ secrets.S3_BUCKET }} - SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} + BUNDLE_KEY: ${{ needs.prepare.outputs.bundle_key }} IMAGE: ${{ matrix.image }} run: | - ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip" + ZIP="$BUNDLE_CACHE/${BUNDLE_KEY}" if [ ! -f "$ZIP" ]; then mkdir -p "$BUNDLE_CACHE" - aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp" + aws s3 cp "s3://${S3_BUCKET}/${BUNDLE_KEY}" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi pushd docker/images From 4b13562d022e8d489242790f77797c7c94b7e48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 26 Oct 2026 20:25:48 +0100 Subject: [PATCH 161/325] :construction_worker: Use only the closest tag in the build key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full `git describe` output is not stable across checkouts: the length of its -g suffix depends on the local object count, so build-bundle and build-docker could compute different keys for the same commit on different runner workspaces. The sha already pins the commit, so the closest tag is enough to tell versions apart. Signed-off-by: David Barragán Merino --- .github/workflows/build-bundle.yml | 10 +++++++--- .github/workflows/build-docker.yml | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index eb4b335313..7c66eb9fea 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -80,13 +80,17 @@ jobs: env: GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | - # Same command the build uses to stamp version.txt, so the key - # always matches what ends up inside the bundle. + # Same command the build uses to stamp version.txt. BUNDLE_VERSION=$(./manage.sh version) SHORT_SHA=$(git rev-parse --short=12 HEAD) + # The key takes only the closest tag (--abbrev=0): the sha already + # pins the commit, and the full describe output is not stable + # across checkouts (its -g suffix length depends on the + # local object count). Must match build-docker.yml. + VERSION_TAG=$(git describe --tags --match '*.*.*' --abbrev=0) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${VERSION_TAG}" >> $GITHUB_OUTPUT echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 9c674eba33..613f806571 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -109,11 +109,12 @@ jobs: # Must match the key computed by build-bundle.yml. BUNDLE_VERSION=$(./manage.sh version) SHORT_SHA=$(git rev-parse --short=12 HEAD) + VERSION_TAG=$(git describe --tags --match '*.*.*' --abbrev=0) echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - echo "build_key=sha-${SHORT_SHA}-${BUNDLE_VERSION}" >> $GITHUB_OUTPUT + echo "build_key=sha-${SHORT_SHA}-${VERSION_TAG}" >> $GITHUB_OUTPUT # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every From e4c1b154e33293707de94d0f9f2ad81c742dd177 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 27 Oct 2026 09:30:28 +0100 Subject: [PATCH 162/325] :sparkles: Add profile name and email to nitrate audit log (#12636) * :sparkles: Add profile name and email to nitrate audit log * :sparkles: Mr changes --- .serena/memories/audit-log.md | 2 +- .../src/app/loggers/audit/consumer_task.clj | 61 ++++++++-- backend/src/app/nitrate.clj | 4 +- .../loggers_audit_consumer_task_test.clj | 104 +++++++++++++++++- .../nitrate_ingest_audit_log_test.clj | 6 + 5 files changed, 157 insertions(+), 20 deletions(-) diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 68d4bcc021..a9766ec80b 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -277,7 +277,7 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert ### How it works today -Every 5 min each archive job drains its own queue (`:audit-log-consumer-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-consumer-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names and emails); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:consumer-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-consumer` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). +Every 5 min each archive job drains its own queue (`:audit-log-consumer-nexus` on flag `:nexus`, cron `0 */5 * * * ?`; `:audit-log-consumer-admin-console` on flag `:admin-console`, cron `30 */5 * * * ?`, 30s offset so the two drains never start on the same second), in 128-row chunks taken oldest-first (`ORDER BY created_at ASC, id ASC`) with `FOR UPDATE OF audit_log` (`SET LOCAL lock_timeout = '30s'`) inside the same transaction that sends and completes them, with 100 ms pauses between chunks. Batches arrive at Nexus in chronological order (it numbers by arrival for order-sensitive consumers). Nexus receives transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri; flag on but no URI raises `:task-not-configured`. The admin console receives only its allowlisted event names (enriched with team names, props emails, and optional top-level `:profile-email` / `:profile-name` from the actor `:profile-id`); anything filtered out counts as done for the admin-console queue. A chunk whose destination refuses it (Nexus non-204 raises `:consumer-chunk-refused`) or throws (transport failure, admin-console error) rolls back and stays for the next tick, and the job aborts as failed: it archives what it can, and the cron retries the rest. Committed chunks stay sent. The send fns return counts or throw carrying the attempted count as `:events`; `run-single-consumer` records the `failed` metric from it, outside every chunk transaction (a local DB failure carries no `:events` and stays uncounted — it is not a destination failure). No `warn` before the throw: the runner reports the aborted job. Each job touches only its own queue, so a fallen Nexus never starves the admin console (nor vice versa). A chunk is **one POST** and the task advances only on 204, so a single oversized event wedges the whole queue: a crash report whose message embeds a whole payload (a serialized file, for example) can push the request past the proxy body limit and the rejection blocks every later event until it is pruned by hand. Frontend crash reports are therefore size-bounded at the source (`mem:frontend/routing-app-shell-subtleties`): `app.common.exceptions/get-hint` and the CLJS `format-throwable` clamp exception messages (`max-message-length`). diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 9fa05c5e70..7270cfae94 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -289,6 +289,19 @@ id->email-prop))) events)) +(defn- collect-actor-ids-needing-profile + "Actor ids (event :profile-id) that still need top-level :profile-email + and/or :profile-name. Skips uuid/zero and non-uuids." + [events] + (into #{} + (keep (fn [event] + (when (or (not (contains? event :profile-email)) + (not (contains? event :profile-name))) + (when-let [id (uuid/coerce (:profile-id event))] + (when-not (uuid/zero? id) + id))))) + events)) + (defn- load-team-names [conn ids] (if (seq ids) @@ -297,12 +310,13 @@ (into {} (map (juxt :id :name) rows))) {})) -(defn- load-profile-emails +(defn- load-profiles + "Batch-load profile email and fullname (including soft-deleted)." [conn ids] (if (seq ids) (let [arr (db/create-array conn "uuid" ids) - rows (db/exec! conn ["SELECT id, email FROM profile WHERE id = ANY(?)" arr])] - (into {} (map (juxt :id :email) rows))) + rows (db/exec! conn ["SELECT id, email, fullname FROM profile WHERE id = ANY(?)" arr])] + (into {} (map (juxt :id identity) rows))) {})) (defn- maybe-assoc-team-name @@ -315,33 +329,58 @@ event))) (defn- maybe-assoc-emails - [event emails] + [event profiles] (update event :props (fn [props] (reduce-kv (fn [props id-key email-key] (if-let [email (and (not (contains? props email-key)) (when-let [id (uuid/coerce (get props id-key))] - (get emails id)))] + (:email (get profiles id))))] (assoc props email-key email) props)) props id->email-prop)))) +(defn- maybe-assoc-actor-profile + "Fill-only top-level :profile-email / :profile-name from the event + actor (:profile-id). Omits keys when the id is missing, zero, the + profile row is absent, or the corresponding column is empty + (fullname defaults to \"\" in the schema)." + [event profiles] + (let [id (uuid/coerce (:profile-id event))] + (if (or (nil? id) (uuid/zero? id)) + event + (if-let [profile (get profiles id)] + (cond-> event + (and (not (contains? event :profile-email)) + (not-empty (:email profile))) + (assoc :profile-email (:email profile)) + + (and (not (contains? event :profile-name)) + (not-empty (:fullname profile))) + (assoc :profile-name (:fullname profile))) + event)))) + (defn- enrich-events-for-admin-console "Enrich allowlisted events before the admin-console ingest: 1. Fill missing :team-name from team id (including soft-deleted). - 2. Fill missing emails from matching id keys (including soft-deleted - profiles): :profile-email, :user-email, :member-email, - :user-who-send-invitation-email." + 2. Fill missing emails from matching id keys in props (including + soft-deleted profiles): :profile-email, :user-email, :member-email, + :user-who-send-invitation-email. + 3. Fill missing top-level :profile-email / :profile-name from the + event actor (:profile-id), including soft-deleted profiles; omit + when there is no usable actor id or the value is empty." [{:keys [::db/conn]} events] (let [team-ids (collect-team-ids-needing-name events) - profile-ids (collect-profile-ids-needing-email events) + profile-ids (into (collect-profile-ids-needing-email events) + (collect-actor-ids-needing-profile events)) team-names (load-team-names conn team-ids) - emails (load-profile-emails conn profile-ids)] + profiles (load-profiles conn profile-ids)] (mapv (fn [event] (-> event (maybe-assoc-team-name team-names) - (maybe-assoc-emails emails))) + (maybe-assoc-emails profiles) + (maybe-assoc-actor-profile profiles))) events))) (defn- send-to-admin-console diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index f911307ea2..e475c531c1 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -483,8 +483,8 @@ (assoc params :throw-on-error? true))) (def ^:private audit-event-keys - [:id :name :type :profile-id :props :context - :created-at :tracked-at :source :ip-addr]) + [:id :name :type :profile-id :profile-email :profile-name + :props :context :created-at :tracked-at :source :ip-addr]) (defn- ->audit-event [event] diff --git a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj index 1fb3efd9e5..17a5a2bbc3 100644 --- a/backend/test/backend_tests/loggers_audit_consumer_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -64,10 +64,14 @@ (insert-pendings! (:id row)) row))) -(defn- admin-console-event-props +(defn- admin-console-event [admin-console-mock] (let [[_ _ params] (:call-args @admin-console-mock)] - (:props (first (:events params))))) + (first (:events params)))) + +(defn- admin-console-event-props + [admin-console-mock] + (:props (admin-console-event admin-console-mock))) (defn- run-both! "Run both consumer jobs, Nexus first (the historical single-run @@ -319,12 +323,16 @@ :user-who-send-invitation (str (:id actor))}) (th/run-task! :audit-log-consumer-admin-console {}) (t/is (:called? @admin-console-mock)) - (let [props (admin-console-event-props admin-console-mock)] + (let [event (admin-console-event admin-console-mock) + props (:props event)] (t/is (= (:name team) (:team-name props))) (t/is (= (:email actor) (:profile-email props))) (t/is (= (:email member) (:user-email props))) (t/is (= (:email member) (:member-email props))) - (t/is (= (:email actor) (:user-who-send-invitation-email props)))) + (t/is (= (:email actor) (:user-who-send-invitation-email props))) + ;; Top-level actor fields (event :profile-id), distinct from props. + (t/is (= (:email actor) (:profile-email event))) + (t/is (= (:fullname actor) (:profile-name event)))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) @@ -340,12 +348,48 @@ {:team-id (:id team) :profile-id (:id other)}) (th/run-task! :audit-log-consumer-admin-console {}) - (let [props (admin-console-event-props admin-console-mock)] + (let [event (admin-console-event admin-console-mock) + props (:props event)] (t/is (= (:name team) (:team-name props))) - (t/is (= (:email other) (:profile-email props)))) + (t/is (= (:email other) (:profile-email props))) + (t/is (= (:email actor) (:profile-email event))) + (t/is (= (:fullname actor) (:profile-name event)))) (let [rows (th/db-exec! ["select * from audit_log"])] (t/is (= 0 (count rows)))))))) +(t/deftest consumer-events-enriches-soft-deleted-actor-profile + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (th/db-update! :profile {:deleted-at (ct/now)} {:id (:id actor)}) + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" + {:team-id (:id team)}) + (th/run-task! :audit-log-consumer-admin-console {}) + (let [event (admin-console-event admin-console-mock)] + (t/is (= (:email actor) (:profile-email event))) + (t/is (= (:fullname actor) (:profile-name event)))))))) + +(t/deftest consumer-events-omits-actor-profile-when-no-usable-id + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)}) + missing (uuid/next)] + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! uuid/zero "create-project" {:team-id (:id team)}) + (insert-audit-row! missing "create-team" {:team-id (:id team)}) + (th/run-task! :audit-log-consumer-admin-console {}) + (t/is (:called? @admin-console-mock)) + (let [[_ _ params] (:call-args @admin-console-mock) + events (:events params) + by-name (into {} (map (juxt :name identity) events)) + zero-evt (get by-name "create-project") + missing-evt (get by-name "create-team")] + (t/is (not (contains? zero-evt :profile-email))) + (t/is (not (contains? zero-evt :profile-name))) + (t/is (not (contains? missing-evt :profile-email))) + (t/is (not (contains? missing-evt :profile-name)))))))) + (t/deftest consumer-events-does-not-overwrite-existing-enrich-props (let [actor (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id actor)})] @@ -371,6 +415,54 @@ (t/is (= "already-inviter@example.com" (:user-who-send-invitation-email props)))))))) +(t/deftest consumer-events-does-not-overwrite-existing-actor-profile-fields + ;; Top-level :profile-email / :profile-name are not stored on the row; + ;; exercise fill-only via the enrich helper directly. + (let [actor (th/create-profile* 1 {:is-active true}) + event {:profile-id (:id actor) + :profile-email "preset@example.com" + :profile-name "Preset Name" + :props {}}] + (db/tx-run! th/*system* + (fn [cfg] + (let [enriched (first (#'consumer/enrich-events-for-admin-console + cfg [event]))] + (t/is (= "preset@example.com" (:profile-email enriched))) + (t/is (= "Preset Name" (:profile-name enriched)))))))) + +(t/deftest consumer-events-fills-missing-actor-profile-field-independently + ;; Each top-level key fills on its own; guards an or→and regression in + ;; collect-actor-ids-needing-profile. + (let [actor (th/create-profile* 1 {:is-active true})] + (db/tx-run! th/*system* + (fn [cfg] + (let [email-only (first (#'consumer/enrich-events-for-admin-console + cfg [{:profile-id (:id actor) + :profile-email "preset@example.com" + :props {}}])) + name-only (first (#'consumer/enrich-events-for-admin-console + cfg [{:profile-id (:id actor) + :profile-name "Preset Name" + :props {}}]))] + (t/is (= "preset@example.com" (:profile-email email-only))) + (t/is (= (:fullname actor) (:profile-name email-only))) + (t/is (= (:email actor) (:profile-email name-only))) + (t/is (= "Preset Name" (:profile-name name-only)))))))) + +(t/deftest consumer-events-omits-empty-actor-profile-name + ;; profile.fullname is NOT NULL DEFAULT ''; empty must not become + ;; :profile-name "". + (let [actor (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id actor)})] + (th/db-update! :profile {:fullname ""} {:id (:id actor)}) + (with-redefs [cf/flags #{:admin-console}] + (with-mocks [admin-console-mock {:target 'app.nitrate/call :return nil}] + (insert-audit-row! (:id actor) "create-project" {:team-id (:id team)}) + (th/run-task! :audit-log-consumer-admin-console {}) + (let [event (admin-console-event admin-console-mock)] + (t/is (= (:email actor) (:profile-email event))) + (t/is (not (contains? event :profile-name)))))))) + (t/deftest consumer-events-enrich-skips-missing-and-non-uuid-ids (let [actor (th/create-profile* 1 {:is-active true})] (with-redefs [cf/flags #{:admin-console}] diff --git a/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj b/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj index b8a679d842..82971f4d93 100644 --- a/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj +++ b/backend/test/backend_tests/nitrate_ingest_audit_log_test.clj @@ -43,6 +43,8 @@ :name "create-project" :type "action" :profile-id p1 + :profile-email "ada@example.com" + :profile-name "Ada" :props {:team-id team-id} :context {} :created-at now @@ -73,6 +75,10 @@ (t/is (= (str e2) (str (:id e2')))) (t/is (= (str p1) (str (:profile-id e1')))) (t/is (= (str p2) (str (:profile-id e2')))) + (t/is (= "ada@example.com" (:profile-email e1'))) + (t/is (= "Ada" (:profile-name e1'))) + (t/is (nil? (:profile-email e2'))) + (t/is (nil? (:profile-name e2'))) (t/is (= "127.0.0.1" (:ip-addr e1'))) (t/is (= "10.0.0.1" (:ip-addr e2'))) (t/is (string? (:created-at e1'))) From 411f661bdbcf7d5a1e6ba8b63efca3ccc8d1c4f5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:20:11 +0100 Subject: [PATCH 163/325] :recycle: Use DS buttons in the delete shared libraries modal (#12554) * :recycle: Use DS buttons in the delete shared libraries modal The delete shared libraries modal (unpublish a library, delete a shared file or project) gets a ghost DS icon button named "Close", placed on the tooltip wrapper, and DS buttons for cancel (secondary) and accept (destructive for the danger style, primary otherwise) instead of raw inputs. The buttons are narrower and the accept button takes the DS destructive red. A new empty library references fixture and a visual test cover the unpublish library modal from the workspace main menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move delete shared libraries modal styles closer to the DS The delete shared libraries stylesheet resolves its legacy modal color aliases to DS tokens, uses a logical property and the DS typography module without a namespace. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. Two unused selectors are commented out with a TODO. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the delete shared libraries modal The SCSS refactor of the delete shared libraries modal left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: &.transparent, .list-wrapper. No visual change. AI-assisted-by: claude-opus-5-5 --- .../get-library-file-references-empty.json | 1 + .../ui/visual-specs/workspace.spec.js | 29 ++++++++++++ frontend/src/app/main/ui/delete_shared.cljs | 29 ++++++------ frontend/src/app/main/ui/delete_shared.scss | 45 ++++++------------- 4 files changed, 60 insertions(+), 44 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-library-file-references-empty.json diff --git a/frontend/playwright/data/workspace/get-library-file-references-empty.json b/frontend/playwright/data/workspace/get-library-file-references-empty.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/frontend/playwright/data/workspace/get-library-file-references-empty.json @@ -0,0 +1 @@ +[] diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index d5621b77ee..91dc893850 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1077,3 +1077,32 @@ test.describe("Export modal", () => { }); }); }); + +test.describe("Unpublish library modal", () => { + test("Shows the close, cancel and unpublish buttons", async ({ page }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-library.json"); + await workspace.mockRPC( + "get-library-file-references?file-id=*", + "workspace/get-library-file-references-empty.json", + ); + await workspace.goToWorkspace({ + fileId: "c1249a66-fce0-8175-8004-7433fe4be8bc", + pageId: "c1249a66-fce0-8175-8004-7433fe4be8bd", + }); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.locator("#file-menu-file").click(); + await page.locator("#file-menu-remove-shared").click(); + + const modal = page.locator("[class*='delete_shared__modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await modal.locator("[class*='modal-header'] button").hover(); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/delete_shared.cljs b/frontend/src/app/main/ui/delete_shared.cljs index 6e88fd41ab..4acd9b066d 100644 --- a/frontend/src/app/main/ui/delete_shared.cljs +++ b/frontend/src/app/main/ui/delete_shared.cljs @@ -11,8 +11,10 @@ [app.main.data.modal :as modal] [app.main.repo :as rp] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] @@ -97,8 +99,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click cancel-fn}]] [:div {:class (stl/css :modal-content)} (when (and (string? subtitle) (not= subtitle "")) @@ -124,14 +129,12 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} (when-not (= cancel-label :omit) - [:input {:class (stl/css :cancel-button) - :type "button" - :value cancel-label - :on-click cancel-fn}]) + [:> button* {:variant "secondary" + :type "button" + :on-click cancel-fn} + cancel-label]) - [:input {:class (stl/css-case :accept-btn true - :danger (= accept-style :danger) - :primary (= accept-style :primary)) - :type "button" - :value accept-label - :on-click accept-fn}]]]]])) + [:> button* {:variant (if (= accept-style :danger) "destructive" "primary") + :type "button" + :on-click accept-fn} + accept-label]]]]])) diff --git a/frontend/src/app/main/ui/delete_shared.scss b/frontend/src/app/main/ui/delete_shared.scss index ab77bb5fd1..5851074638 100644 --- a/frontend/src/app/main/ui/delete_shared.scss +++ b/frontend/src/app/main/ui/delete_shared.scss @@ -4,15 +4,13 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/basic-rules.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } } .modal-container { @@ -23,33 +21,30 @@ grid-template-rows: auto minmax(0, 1fr) auto; } -.list-wrapper { - display: grid; - grid-template-rows: auto 1fr auto; - max-height: 100%; -} - .modal-title { - @include t.use-typography("headline-medium"); + @include use-typography("headline-medium"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: grid; gap: var(--sp-s); } .element-list { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); overflow-y: auto; margin-block: 0; } @@ -58,18 +53,6 @@ @extend %modal-action-btns; } -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - .modal-scd-msg { margin-block: 0; } @@ -77,8 +60,8 @@ .modal-scd-msg, .modal-subtitle, .modal-msg { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); line-height: 1.5; } From 9854f8e54f125fb0c60a6782ae0ab69100ff7406 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:23:55 +0100 Subject: [PATCH 164/325] :recycle: Move dashboard subscription styles off the deprecated styles (#12538) * :recycle: Move dashboard subscription styles off the deprecated styles The subscription stylesheet inlines the deprecated button-style and flex-center mixins, resolves its legacy color aliases and sizes to DS tokens, uses logical properties and the DS typography module without a namespace. Three rules that match nothing (the arrow svg, .cta-link and .open-arrow) are commented out with a TODO. The subscription controls need no DS change: the plan card, the current plan card and the manage subscription link have no DS equivalent, and they use no legacy icons. refactor/ stays only for the power up menu item placeholder and the shared dashboard header styles. A visual test covers the sidebar plan card (closed and open), the team settings plan and the members seats warnings. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard subscription The SCSS refactor of the dashboard subscription left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .icon-dropdown svg, .cta-link, .open-arrow. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 101 +++++++++++++++++ .../app/main/ui/dashboard/subscription.scss | 104 ++++++++---------- 2 files changed, 149 insertions(+), 56 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 1b2ce0d483..ee79bdfc75 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -337,3 +337,104 @@ test.describe("Delete account modal", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Subscription controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + // An unlimited trial that the logged-in user owns, as in + // specs/subscriptions-dashboard.spec.js. + const setupSubscription = async ( + page, + members, + secondTeam = true, + usage = "subscription/get-subscription-usage.json", + ) => { + await DashboardPage.mockConfigFlags(page, [ + "enable-subscriptions", + "disable-onboarding", + ]); + await DashboardPage.mockRPC( + page, + "get-profile", + "subscription/get-profile-unlimited-subscription.json", + ); + await DashboardPage.mockRPC(page, "get-subscription-usage", usage); + await DashboardPage.mockRPC( + page, + "get-team-info", + "subscription/get-team-info-subscriptions.json", + ); + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await DashboardPage.mockRPC( + page, + "get-teams", + "subscription/get-teams-unlimited-subscription-owner.json", + ); + if (secondTeam) { + await DashboardPage.mockRPC( + page, + "get-projects?team-id=*", + "dashboard/get-projects-second-team.json", + ); + } + await DashboardPage.mockRPC(page, "get-team-members?team-id=*", members); + await DashboardPage.mockRPC( + page, + "get-team-stats?team-id=*", + "dashboard/get-team-stats.json", + ); + await dashboardPage.mockRPC( + "push-audit-events", + "workspace/audit-event-empty.json", + ); + return dashboardPage; + }; + + test("Sidebar plan card", async ({ page }) => { + const dashboardPage = await setupSubscription( + page, + "subscription/get-team-members-subscription-owner.json", + false, + "subscription/get-subscription-usage-one-editor.json", + ); + await dashboardPage.goToDashboard(); + + const name = page.getByTestId("subscription-name"); + await expect(name).toHaveText("Unlimited plan (trial)"); + const card = page.locator("[class*='cta-power-up']").first(); + await page.mouse.move(640, 300); + await expect(card).toHaveScreenshot(shot); + + await name.click(); + await page.mouse.move(640, 300); + await expect(card).toHaveScreenshot(shot); + }); + + test("Team settings plan", async ({ page }) => { + const dashboardPage = await setupSubscription( + page, + "subscription/get-team-members-subscription-owner.json", + ); + await dashboardPage.goToSecondTeamSettingsSection(); + + await expect( + page.getByRole("button", { name: "Manage your subscription" }), + ).toBeVisible(); + await expect( + page.locator("[class$='subscription__team']"), + ).toHaveScreenshot(shot); + }); + + test("Members seats warning", async ({ page }) => { + const dashboardPage = await setupSubscription( + page, + "subscription/get-team-members-subscription-eight-member.json", + ); + await dashboardPage.goToSecondTeamMembersSection(); + + await expect(page.getByTestId("cta")).toHaveCount(2); + await page.mouse.move(640, 700); + await expect(page).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 43a6d9419b..6cfe4c2e9a 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -4,10 +4,16 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the menu item placeholder (%menu-item-base) of the +// power up submenu. @use "refactor/common-refactor.scss" as deprecated; + +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; @use "ds/spacing.scss" as *; .cta-power-up { @@ -26,8 +32,9 @@ } .cta-top-section { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; display: grid; color: var(--color-foreground-secondary); grid-template-columns: 1fr auto; @@ -44,17 +51,11 @@ } .icon-dropdown { - @include deprecated.flex-center; - - height: 100%; - width: var(--sp-l); -} - -.icon-dropdown svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - transform: rotate(90deg); + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + inline-size: var(--sp-l); } .cta-bottom-section { @@ -69,12 +70,14 @@ } .cta-bottom-section .content { - @include t.use-typography("body-medium"); - @include deprecated.button-style; + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; color: var(--color-foreground-secondary); display: inline-block; - text-align: left; + text-align: start; } .cta-bottom-button { @@ -87,72 +90,67 @@ .cta-text, .cta-title { - text-align: left; + text-align: start; } .cta-title { - @include t.use-typography("body-small"); + @include use-typography("body-small"); margin-block-end: var(--sp-xs); } .highlighted .cta-title { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: 0; } .cta-text { - @include t.use-typography("title-small"); + @include use-typography("title-small"); } .highlighted .cta-text { - @include t.use-typography("body-large"); + @include use-typography("body-large"); color: var(--color-foreground-primary); } .cta-bottom-section .content a { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-accent-tertiary); margin-inline-start: var(--sp-xs); } -.cta-link { - @include deprecated.button-style; - - align-self: end; - margin-inline-start: var(--sp-xs); -} - .team { display: grid; grid-auto-rows: min-content; gap: var(--sp-s); - max-width: deprecated.$s-1000; - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; } .team-label { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .team-text { - @include t.use-typography("title-medium"); + @include use-typography("title-medium"); color: var(--color-foreground-primary); } .manage-subscription-link { - @include deprecated.button-style; - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; color: var(--color-accent-tertiary); display: flex; - margin-block-start: -8px; + margin-block-start: calc(-1 * var(--sp-s)); padding: 0; } @@ -178,21 +176,15 @@ cursor: pointer; &:hover { - color: var(--menu-foreground-color-hover); - - .open-arrow { - svg { - stroke: var(--menu-foreground-color-hover); - } - } + color: var(--color-foreground-primary); } } .members-cta { - height: fit-content; + block-size: fit-content; margin-block-start: var(--sp-s); - margin-inline-start: deprecated.$s-52; - max-width: deprecated.$s-220; + margin-inline-start: px2rem(52); + max-inline-size: px2rem(220); .cta-title { line-height: 1.2; @@ -200,7 +192,7 @@ } .dashboard-cta { - height: fit-content; + block-size: fit-content; margin: var(--sp-l); .cta-title { @@ -209,7 +201,7 @@ } .cta-message { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); line-height: 1; @@ -231,13 +223,13 @@ } .nitrate-title { - @include t.use-typography("body-large"); + @include use-typography("body-large"); color: var(--color-foreground-primary); } .nitrate-info { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); margin-block: var(--sp-s) var(--sp-xxl); @@ -249,8 +241,8 @@ } .nitrate-bottom-button { - width: fit-content; - height: fit-content; + inline-size: fit-content; + block-size: fit-content; padding: var(--sp-s) var(--sp-m); } @@ -271,14 +263,14 @@ } .nitrate-current-plan-label { - @include t.use-typography("body-small"); + @include use-typography("body-small"); padding-block-end: var(--sp-xs); color: var(--color-foreground-secondary); } .nitrate-current-plan-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-primary); } From 19fc9ebce870508b3d9c1c109544984073302cc6 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:47:07 +0100 Subject: [PATCH 165/325] :recycle: Use DS icons in the viewer flows and interactions menus (#12520) * :recycle: Use DS icons in the viewer flows and interactions menus The flows and interactions triggers in the viewer header were divs with a click handler, and their menus lived inside them. They are now buttons with aria-expanded and aria-haspopup, and each menu sits next to its button in a wrapper, since a button cannot hold the menu entries. The play, arrow and tick icons use DS icons. The menu entries stay as they are. A visual test covers the interactions menu closed and open; the test files have no flows, and the flows trigger shares its styles. AI-assisted-by: claude-opus-5-5 * :recycle: Move viewer interactions styles off the deprecated styles Spacing, sizes, borders, typographies and color aliases move to DS tokens and logical properties. The DS icons take their color from the text and no longer shrink, so the old icon placeholders go away. The DS body-small line-height is more compact, so the trigger labels sit 1px higher. The refactor/ import stays only for the shared menu placeholders. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 33 ++++++++ .../src/app/main/ui/viewer/interactions.cljs | 40 ++++++---- .../src/app/main/ui/viewer/interactions.scss | 79 +++++++------------ 3 files changed, 89 insertions(+), 63 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 2085eca3e3..80ed3f7d52 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -362,3 +362,36 @@ test.describe("Inspect code tab", () => { }); }); }); + +test.describe("Interactions menu", () => { + test("Shows the interactions menu closed and open", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithMultipleBoards(); + await viewerPage.goToViewer({ + fileId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0", + pageId: "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3", + }); + + const trigger = page + .getByText("Interactions", { exact: true }) + .locator(".."); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + // Closed: the "Interactions" trigger with its arrow. + await expect(trigger).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + await trigger.click(); + const menu = page.getByText("Show interactions", { exact: true }); + await expect(menu).toBeVisible(); + await page.mouse.move(640, 600); + // Open: the menu with the selected mode ticked. + await expect(menu.locator("../..")).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index bd96266d37..0aff863b5d 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -18,8 +18,8 @@ [app.main.features :as features] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as h] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.viewer.shapes :as shapes] [app.main.ui.viewer.viewport-common :as vpc] [app.main.ui.viewer.viewport-wasm :as viewport.wasm] @@ -218,11 +218,17 @@ (st/emit! (dv/go-to-frame (:starting-frame flow))))))] (when flows - [:div {:on-click toggle-dropdown - :class (stl/css :view-options)} - [:span {:class (stl/css :icon)} deprecated-icon/play] - [:span {:class (stl/css :dropdown-title)} (:name current-flow)] - [:span {:class (stl/css :icon-dropdown)} deprecated-icon/arrow] + [:div {:class (stl/css :view-options-wrapper)} + [:button {:type "button" + :class (stl/css :view-options) + :aria-expanded show-dropdown? + :aria-haspopup "menu" + :on-click toggle-dropdown} + [:> icon* {:icon-id i/play :size "s" :class (stl/css :icon)}] + [:span {:class (stl/css :dropdown-title)} (:name current-flow)] + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :icon-dropdown)}]] [:& dropdown {:show show-dropdown? :on-close hide-dropdown} [:ul {:class (stl/css :dropdown)} @@ -236,7 +242,7 @@ :on-click select-flow} [:span {:class (stl/css :label)} (:name flow)] (when (= flow-id (:id current-flow)) - [:span {:class (stl/css :icon)} deprecated-icon/tick])])]]]))) + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])])]]]))) (mf/defc interactions-menu* [{:keys [interactions-mode]}] @@ -253,10 +259,16 @@ (dom/stop-propagation event) (st/emit! (dv/set-interactions-mode mode)))))] - [:div {:on-click toggle-dropdown - :class (stl/css :view-options)} - [:span {:class (stl/css :dropdown-title)} (tr "viewer.header.interactions")] - [:span {:class (stl/css :icon-dropdown)} deprecated-icon/arrow] + [:div {:class (stl/css :view-options-wrapper)} + [:button {:type "button" + :class (stl/css :view-options) + :aria-expanded (deref show-dropdown?) + :aria-haspopup "menu" + :on-click toggle-dropdown} + [:span {:class (stl/css :dropdown-title)} (tr "viewer.header.interactions")] + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :icon-dropdown)}]] [:& dropdown {:show @show-dropdown? :on-close hide-dropdown} [:ul {:class (stl/css :dropdown)} @@ -267,7 +279,7 @@ [:span {:class (stl/css :label)} (tr "viewer.header.dont-show-interactions")] (when (= interactions-mode :hide) - [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] [:li {:class (stl/css-case :dropdown-element true :selected (= interactions-mode :show)) @@ -275,7 +287,7 @@ :data-mode "show"} [:span {:class (stl/css :label)} (tr "viewer.header.show-interactions")] (when (= interactions-mode :show) - [:span {:class (stl/css :icon)} deprecated-icon/tick])] + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])] @@ -286,7 +298,7 @@ [:span {:class (stl/css :label)} (tr "viewer.header.show-interactions-on-click")] (when (= interactions-mode :show-on-click) - [:span {:class (stl/css :icon)} deprecated-icon/tick])]]]])) + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :icon)}])]]]])) (defn animate-go-to-frame [animation current-viewport orig-viewport current-size orig-size wrapper-size] diff --git a/frontend/src/app/main/ui/viewer/interactions.scss b/frontend/src/app/main/ui/viewer/interactions.scss index b17ac08b84..669e02940f 100644 --- a/frontend/src/app/main/ui/viewer/interactions.scss +++ b/frontend/src/app/main/ui/viewer/interactions.scss @@ -4,94 +4,75 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for the shared menu placeholders, which move to the DS with the +// menus and dropdowns. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +.view-options-wrapper { + position: relative; +} .view-options { - @include deprecated.body-small-typography; + @include use-typography("body-small"); display: flex; align-items: center; - position: relative; - gap: deprecated.$s-4; - height: deprecated.$s-32; - border-radius: deprecated.$br-8; - background-color: var(--input-background-color); - padding: deprecated.$s-8; + border: none; + gap: var(--sp-xs); + block-size: $sz-32; + border-radius: $br-8; + background-color: var(--color-background-tertiary); + padding: var(--sp-s); cursor: pointer; } .dropdown-title { - @include deprecated.body-small-typography; + @include use-typography("body-small"); flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .label { flex-grow: 1; - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .dropdown { @extend %menu-dropdown; - right: deprecated.$s-2; - top: calc(deprecated.$s-2 + deprecated.$s-48); - width: deprecated.$s-272; - padding: deprecated.$s-6; - max-height: calc(100vh - 3 * (deprecated.$s-2 + deprecated.$s-48)); + inset-inline-end: px2rem(2); + inset-block-start: px2rem(50); + inline-size: px2rem(272); + padding: px2rem(6); + max-block-size: calc(100vh - #{px2rem(150)}); overflow: auto; } .dropdown-element { @extend %dropdown-element-base; - min-height: deprecated.$s-32; - - .icon { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } + min-block-size: $sz-32; &:hover .label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } } .dropdown-element.selected { .label { - color: var(--input-foreground-color-active); - } - - .icon svg { - stroke: var(--input-foreground-color); + color: var(--color-foreground-primary); } } .icon, .icon-dropdown { - @include deprecated.flex-center; - - height: 100%; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } -} - -.icon-dropdown svg { - transform: rotate(90deg); + flex-shrink: 0; + color: var(--color-foreground-secondary); } // breakpoint 1013px From afd838ea5b4114398c55b24bcd27f5c4939bf881 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 10:58:13 +0100 Subject: [PATCH 166/325] :recycle: Use DS buttons in the dashboard team modals (#12537) * :recycle: Use DS buttons in the dashboard team modals The close buttons of the create/rename team modal, the team permission notice and the two "before you leave" modals become ghost DS icon buttons named "Close"; the class that places them goes on the tooltip wrapper. The cancel and "promote and leave" inputs of the leave modals become DS buttons: secondary, and destructive once a member is picked (primary and disabled before). Two fixtures make the leave and reassign modal testable (the logged-in user owns the second team, which has another member). A visual test covers the create team modal and the leave and reassign modal. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard team modal styles closer to the DS The change owner and team form stylesheets resolve their legacy color aliases, spacing and typography mixins to DS tokens and use the DS typography module without a namespace. The legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px): the "before you leave" title grows 1px and the modal 2px. Unused selectors are commented out with a TODO. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor, and the input placeholders. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard team modals The SCSS refactor of the dashboard team modals left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .input-wrapper, .cancel-button, &.danger. No visual change. AI-assisted-by: claude-opus-5-5 --- .../dashboard/get-team-members-owner.json | 30 ++++++++++ .../dashboard/get-teams-second-owner.json | 48 +++++++++++++++ .../ui/visual-specs/visual-dashboard.spec.js | 48 +++++++++++++++ .../app/main/ui/dashboard/change_owner.cljs | 60 ++++++++++--------- .../app/main/ui/dashboard/change_owner.scss | 56 +++++++---------- .../src/app/main/ui/dashboard/team_form.cljs | 17 ++++-- .../src/app/main/ui/dashboard/team_form.scss | 27 ++++----- 7 files changed, 203 insertions(+), 83 deletions(-) create mode 100644 frontend/playwright/data/dashboard/get-team-members-owner.json create mode 100644 frontend/playwright/data/dashboard/get-teams-second-owner.json diff --git a/frontend/playwright/data/dashboard/get-team-members-owner.json b/frontend/playwright/data/dashboard/get-team-members-owner.json new file mode 100644 index 0000000000..10771dcd4d --- /dev/null +++ b/frontend/playwright/data/dashboard/get-team-members-owner.json @@ -0,0 +1,30 @@ +[ + { + "~:is-admin": false, + "~:email": "bar@example.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Han Solo", + "~:fullname": "Han Solo", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u1e162163-87b7-805b-8005-5fd05514b6d3", + "~:profile-id": "~u1e162163-87b7-805b-8005-5fd05514b6d3", + "~:created-at": "~m1733324626956" + }, + { + "~:is-admin": true, + "~:email": "foo@example.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Princesa Leia", + "~:fullname": "Princesa Leia", + "~:is-owner": true, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:profile-id": "~uf56647eb-19a7-8115-8003-b6bc939ecd1b", + "~:created-at": "~m1713533116365" + } +] diff --git a/frontend/playwright/data/dashboard/get-teams-second-owner.json b/frontend/playwright/data/dashboard/get-teams-second-owner.json new file mode 100644 index 0000000000..cb8eb7f114 --- /dev/null +++ b/frontend/playwright/data/dashboard/get-teams-second-owner.json @@ -0,0 +1,48 @@ +[ + { + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116375", + "~:is-default": true + }, + { + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Second team", + "~:modified-at": "~m1701164272671", + "~:id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:created-at": "~m1701164272671", + "~:is-default": false + } +] diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index ee79bdfc75..5b271306cb 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -438,3 +438,51 @@ test.describe("Subscription controls", () => { await expect(page).toHaveScreenshot(shot); }); }); + +test.describe("Team modals controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Create team modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.teamDropdown.click(); + await page.getByText("Create new team").click(); + + const modal = page.locator("[class*='modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await modal.locator("[class*='modal-header'] button").hover(); + await expect(modal).toHaveScreenshot(shot); + }); + + test("Leave and reassign modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // The logged-in user owns the second team, which has another member. + await dashboardPage.mockRPC( + "get-teams", + "dashboard/get-teams-second-owner.json", + ); + await dashboardPage.mockRPC( + "get-team-members?team-id=*", + "dashboard/get-team-members-owner.json", + ); + await dashboardPage.goToSecondTeamMembersSection(); + await expect(page.getByText("role")).toBeVisible(); + + const rows = page.locator("[class*='table-rows']"); + await rows + .locator("[class*='menu-btn']") + .or(rows.getByRole("button", { name: "Options" })) + .first() + .click(); + await rows.getByText("Leave team").click(); + + const modal = page.locator("[class*='modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/change_owner.cljs b/frontend/src/app/main/ui/dashboard/change_owner.cljs index b6eb800030..e4a1cc3498 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.cljs +++ b/frontend/src/app/main/ui/dashboard/change_owner.cljs @@ -12,7 +12,9 @@ [app.common.uuid :as uuid] [app.main.data.modal :as modal] [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -45,8 +47,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} (tr "modals.leave-and-reassign.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:p {:class (stl/css :modal-msg)} @@ -62,19 +67,16 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] - [:input.accept-button - {:type "button" - :class (stl/css-case :accept-btn true - :danger (:valid @form) - :global/disabled (not (:valid @form))) - :disabled (not (:valid @form)) - :value (tr "modals.leave-and-reassign.promote-and-leave") - :on-click on-accept}]]]]])) + [:> button* {:variant (if (:valid @form) "destructive" "primary") + :type "button" + :disabled (not (:valid @form)) + :on-click on-accept} + (tr "modals.leave-and-reassign.promote-and-leave")]]]]])) @@ -149,8 +151,11 @@ [:div {:class (stl/css :modal-organization-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-organization-title)} (tr "modals.before-leave-organization.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} (if (zero? num-teams-to-delete) @@ -169,16 +174,13 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] - [:input.accept-button - {:type "button" - :class (stl/css-case :accept-btn true - :danger all-valid? - :global/disabled (not all-valid?)) - :disabled (not all-valid?) - :value (tr "modals.leave-and-reassign.promote-and-leave") - :on-click on-accept}]]]]])) + [:> button* {:variant (if all-valid? "destructive" "primary") + :type "button" + :disabled (not all-valid?) + :on-click on-accept} + (tr "modals.leave-and-reassign.promote-and-leave")]]]]])) diff --git a/frontend/src/app/main/ui/dashboard/change_owner.scss b/frontend/src/app/main/ui/dashboard/change_owner.scss index df51c6e86a..d066c372d4 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.scss +++ b/frontend/src/app/main/ui/dashboard/change_owner.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*, to move with the +// modals refactor) and the input placeholder (%input-with-label, with the inputs). @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/z-index.scss" as *; @@ -20,48 +22,34 @@ } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; -} - -.input-wrapper { - @extend %input-with-label; - @include deprecated.body-small-typography; + margin-block-end: var(--sp-xxl); } .action-buttons { @extend %modal-action-btns; } -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - .modal-msg { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .teams-container { @@ -75,34 +63,34 @@ display: grid; grid-template-columns: 1fr 2fr; align-items: center; - width: 100%; + inline-size: 100%; } .modal-organization-container { @extend %modal-container-base; overflow-y: auto; - max-height: $sz-512; + max-block-size: $sz-512; } .modal-organization-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .modal-organization-msg { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .team-name { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .team-member { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 81803fa4a1..08f6dcaad6 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -18,7 +18,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -118,8 +119,11 @@ [:h2 {:class (stl/css :modal-title)} (tr "labels.create-team")]) - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:& fm/input {:type "text" @@ -162,7 +166,10 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:div message]]]])) diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index 946057fa95..1047c8f3d8 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -4,9 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*, to move with the +// modals refactor) and the input placeholder (%input-element-label, with the inputs). @use "refactor/common-refactor.scss" as deprecated; @use "ds/_utils.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -21,20 +23,23 @@ } .modal-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { margin-block-end: var(--sp-xxl); color: var(--color-foreground-secondary); - @include t.use-typography("body-large"); + @include use-typography("body-large"); } .team-form { @@ -52,24 +57,16 @@ flex-direction: column; gap: var(--sp-xs); align-items: flex-start; - width: 100%; + inline-size: 100%; border: none; background-color: transparent; - height: 100%; + block-size: 100%; } .action-buttons { @extend %modal-action-btns; } -.cancel-button { - @extend %modal-cancel-btn; -} - .accept-btn { @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } } From 33d304c0afb9507c541f8fa86963c051cdf73273 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 11:41:19 +0100 Subject: [PATCH 167/325] :recycle: Use DS icons in the dashboard sidebar (#12534) * :recycle: Use DS icons in the dashboard sidebar The search, clear search, pinned projects placeholder and logout icons of the dashboard sidebar use the DS icons, and they get their color from the color property. The two search buttons had untranslated test names as their labels ("dashboard-search"); they are now named "Search" and "Clear search". The pin keeps its place: the DS small icon draws 12px inside a 16px box, so its margins shrink by 2px. New key: dashboard.clear-search (en, es). The dashboard page object looks the search button up by its new name. A visual test covers the search box with and without a term, the pinned projects placeholder and the profile menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard sidebar styles off the deprecated styles Legacy color aliases resolve to their DS tokens, deprecated mixins, sizes and font variables are inlined with DS values, physical properties become logical ones and the DS typography module is used without a namespace. An invalid max-width with two values, which the browser ignored, is gone. Two rules that match nothing are commented out with a TODO: the profile menu arrow svg rule (the arrow is the DS icon svg itself) and the upgrade plan section, which sidebar.cljs does not use. refactor/ stays only for the profile menu placeholders (%menu-dropdown, %menu-item-base) and the shared dashboard header styles (common-dashboard). No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard sidebar The SCSS refactor of the dashboard sidebar left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .profile-dropdown-item .open-arrow svg, .upgrade-plan-section, .penpot-free, .no-limits, .power-up. No visual change. AI-assisted-by: claude-opus-5-5 --- frontend/playwright/ui/pages/DashboardPage.js | 5 +- .../ui/visual-specs/visual-dashboard.spec.js | 28 +++ .../src/app/main/ui/dashboard/sidebar.cljs | 37 ++-- .../src/app/main/ui/dashboard/sidebar.scss | 207 ++++++++---------- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 6 files changed, 145 insertions(+), 140 deletions(-) diff --git a/frontend/playwright/ui/pages/DashboardPage.js b/frontend/playwright/ui/pages/DashboardPage.js index 8532455c88..8d0f7df082 100644 --- a/frontend/playwright/ui/pages/DashboardPage.js +++ b/frontend/playwright/ui/pages/DashboardPage.js @@ -82,7 +82,10 @@ export class DashboardPage extends BaseWebSocketPage { this.fontsLink = this.sidebar.getByText("Fonts"); this.librariesLink = this.sidebar.getByText("Libraries"); - this.searchButton = page.getByRole("button", { name: "dashboard-search" }); + this.searchButton = page.getByRole("button", { + name: "Search", + exact: true, + }); this.searchInput = page.getByPlaceholder("Search…"); this.teamDropdown = this.sidebar.getByRole("button", { diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 5b271306cb..320d855e64 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -486,3 +486,31 @@ test.describe("Team modals controls", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Dashboard sidebar controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Search box, pinned placeholder and profile menu", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + const search = page.locator("[class*='sidebar-search']"); + await expect(search).toBeVisible(); + await expect(search).toHaveScreenshot(shot); + + await expect(page.getByTestId("pinned-projects")).toHaveScreenshot(shot); + + // A search term swaps the search icon for the clear button. + await page.locator("#search-input").fill("test"); + await expect(page).toHaveURL(/search/); + const clear = search.locator("button"); + await clear.hover(); + await expect(search).toHaveScreenshot(shot); + + await page.getByTestId("profile-btn").click(); + await expect(page.getByTestId("logout-profile-opt")).toBeVisible(); + await expect( + page.getByTestId("logout-profile-opt").locator("xpath=.."), + ).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index b528a5c788..d9514fb927 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -37,7 +37,6 @@ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.layout.menu :refer [context-menu*]] [app.main.ui.hooks :refer [use-focus-timer-ref]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.nitrate.nitrate-form] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] @@ -48,18 +47,6 @@ [goog.functions :as f] [rumext.v2 :as mf])) -(def ^:private clear-search-icon - (deprecated-icon/icon-xref :delete-text (stl/css :clear-search-icon))) - -(def ^:private search-icon - (deprecated-icon/icon-xref :search (stl/css :search-icon))) - -(def ^:private pin-icon - (deprecated-icon/icon-xref :pin (stl/css :pin-icon))) - -(def ^:private exit-icon - (deprecated-icon/icon-xref :exit (stl/css :exit-icon))) - (defn schedule-focus-by-id! [ref element-id] (when-let [h (mf/ref-val ref)] @@ -101,7 +88,6 @@ (schedule-focus-by-id! focus-timer-ref (str project-id)) (st/emit! (dcm/go-to-dashboard-files :project-id project-id))))) - on-edit-open (mf/use-fn #(swap! local* assoc :edition? true)) @@ -239,15 +225,19 @@ (if (or @focused? (seq search-term)) [:button {:class (stl/css :search-btn :clear-search-btn) :tab-index "0" - :aria-label "dashboard-clear-search" + :aria-label (tr "dashboard.clear-search") :on-click on-clear-click :on-key-down handle-clear-search} - clear-search-icon] + [:> icon* {:icon-id i/delete-text + :class (stl/css :search-icon) + :aria-hidden true}]] [:button {:class (stl/css :search-btn) - :aria-label "dashboard-search" + :aria-label (tr "labels.search") :on-click on-clear-click} - search-icon])])) + [:> icon* {:icon-id i/search + :class (stl/css :search-icon) + :aria-hidden true}]])])) (mf/defc sidebar-content* {::mf/private true} @@ -364,7 +354,6 @@ :keyboard-action go-drafts-with-key} [:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]] - [:div {:class (stl/css :sidebar-content-section)} [:div {:class (stl/css :sidebar-section-title)} (tr "labels.sources")] @@ -384,7 +373,6 @@ :keyboard-action go-libs-with-key} [:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]] - [:div {:class (stl/css :sidebar-content-section) :data-testid "pinned-projects"} [:div {:class (stl/css :sidebar-section-title)} @@ -399,7 +387,9 @@ :team-id (:id team) :is-selected (= (:id item) (:id project))}])] [:div {:class (stl/css :sidebar-empty-placeholder)} - pin-icon + [:> icon* {:icon-id i/pin + :size "s" + :class (stl/css :pin-icon)}] [:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]] [:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]])) @@ -673,7 +663,6 @@ [:> dashboard-cta* {:profile profile}] [:> subscription-sidebar* {:profile profile}]))) - (when (and team profile) [:& comments-section {:profile profile @@ -704,7 +693,6 @@ [:li {:class (stl/css :profile-separator)}] - [:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true) :on-click on-menu-click :on-key-down (fn [event] @@ -749,7 +737,8 @@ [:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item :item-with-icon) :on-click handle-logout-click :data-testid "logout-profile-opt"} - exit-icon + [:> icon* {:icon-id i/exit + :class (stl/css :exit-icon)}] (tr "labels.logout")]] (when (and team profile) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 4bbd6d243b..424f56427b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -4,14 +4,21 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/colors.scss" as *; @use "ds/spacing.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @use "ds/z-index.scss" as *; +@use "ds/mixins.scss" as *; + +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; + +// refactor/ stays for the menu placeholders (%menu-dropdown, +// %menu-item-base) of the profile menu and its submenus. @use "common/refactor/common-refactor.scss" as deprecated; // SIDEBAR COMPONENT @@ -20,19 +27,19 @@ grid-row: 1 / span 2; grid-column: 1 / span 2; grid-template-rows: auto 1fr auto; - height: 100%; - width: 100%; + block-size: 100%; + inline-size: 100%; margin: 0 var(--sp-l) 0 0; - border-right: $b-1 solid var(--panel-border-color); - background-color: var(--panel-background-color); + border-inline-end: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-primary); } // SIDEBAR CONTENT COMPONENT .sidebar-content-wrapper { display: flex; flex-direction: column; - min-height: 0; - height: 100%; + min-block-size: 0; + block-size: 100%; overflow: hidden; } @@ -41,7 +48,7 @@ grid-template-rows: auto auto auto auto 1fr; gap: var(--sp-xxl); flex: 1; - min-height: 0; + min-block-size: 0; padding: 0; overflow: hidden auto; } @@ -52,17 +59,17 @@ } .separator { - height: var(--sp-xxs); - width: 94%; - margin-left: 3%; + block-size: var(--sp-xxs); + inline-size: 94%; + margin-inline-start: 3%; } .overflow-separator { - border-bottom: $b-1 solid var(--color-background-quaternary); + border-block-end: $b-1 solid var(--color-background-quaternary); } .sidebar-section-title { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); padding: 0 var(--sp-s) var(--sp-s) var(--sp-xxl); color: var(--color-foreground-secondary); @@ -82,31 +89,33 @@ cursor: pointer; &:hover { - background-color: var(--sidebar-element-background-color-hover); + background-color: var(--color-background-secondary); + // The item labels have no class. span { - color: var(--sidebar-element-foreground-color-hover); + color: var(--color-accent-primary); } } &.current { - background-color: var(--sidebar-element-background-color-selected); + background-color: var(--color-background-quaternary); .element-title { - color: var(--sidebar-element-foreground-color-selected); + color: var(--color-accent-primary); } } } +// The icon has no class. .recent-projects svg { - stroke: var(--main-icon-foreground); + stroke: var(--color-foreground-primary); } .sidebar-link { display: block; padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-xxl); - font-weight: deprecated.$fw400; - width: 100%; + font-weight: 400; + inline-size: 100%; &:hover { text-decoration: none; @@ -118,31 +127,33 @@ } .element-title { - @include deprecated.text-ellipsis; + @include text-ellipsis; - width: deprecated.$s-256; + inline-size: px2rem(256); color: var(--color-foreground-primary); - font-size: deprecated.$fs-14; + font-size: px2rem(14); } // Pinned projects .sidebar-empty-placeholder { padding: var(--sp-m); - color: var(--empty-message-foreground-color); + color: var(--color-foreground-secondary); display: flex; align-items: center; } +// The DS small icon draws 12px inside a 16px box: 2px less margin on each +// side keeps the drawing and the text where they were. .pin-icon { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - margin: 0 var(--sp-m); + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-secondary); + margin: 0 px2rem(10); } .empty-text { - font-size: deprecated.$fs-12; + font-size: px2rem(12); } // Search @@ -155,55 +166,58 @@ border: $b-1 solid transparent; margin: 0 var(--sp-l); border-radius: $br-8; - background-color: var(--search-bar-input-background-color); + background-color: var(--color-background-tertiary); } .input-text { - @include t.use-typography("title-small"); + @include use-typography("title-small"); - height: $sz-40; - width: 100%; + block-size: $sz-40; + inline-size: 100%; padding: $sz-6 var(--sp-m); margin: 0; border: transparent; border-radius: $br-8; background: transparent; - color: var(--search-bar-foreground-color); + color: var(--color-foreground-primary); &:focus, &:focus-within, &:focus-visible { outline: none; - border: $b-1 solid var(--search-bar-input-border-color-focus); + border: $b-1 solid var(--color-accent-primary); } ::placeholder { - color: var(--search-bar-placeholder-foreground-color); + color: var(--color-foreground-secondary); } } .search-btn { - @include deprecated.button-style; - @include deprecated.flex-center; + border: none; + background: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + --sidebar-search-foreground-color: var(--color-foreground-secondary); + + color: var(--sidebar-search-foreground-color); position: absolute; - right: 0; - height: var(--sp-xxl); - width: var(--sp-xxxl); + inset-inline-end: 0; + block-size: var(--sp-xxl); + inline-size: var(--sp-xxxl); padding: 0 var(--sp-s); } -.search-icon, -.clear-search-btn { - @extend %button-icon; - - --sidebar-search-foreground-color: var(--search-bar-icon-foreground-color); - - stroke: var(--sidebar-search-foreground-color); +.search-icon { + flex-shrink: 0; + stroke-width: 1px; } .clear-search-btn:hover { - --sidebar-search-foreground-color: var(--search-bar-icon-foreground-color-hover); + --sidebar-search-foreground-color: var(--color-accent-primary); } // Profile @@ -212,33 +226,32 @@ display: grid; grid-template-columns: 1fr auto; padding: var(--sp-m) var(--sp-l); - border-top: $b-1 solid var(--panel-border-color); - background-color: var(--profile-section-background-color); + border-block-start: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-tertiary); cursor: pointer; } .profile { - @include deprecated.button-style; - + border: none; + background: none; display: grid; grid-template-columns: auto 1fr; gap: var(--sp-s); cursor: pointer; - text-align: left; + text-align: start; } .profile-fullname { - @include t.use-typography("title-small"); - @include deprecated.text-ellipsis; + @include use-typography("title-small"); + @include text-ellipsis; align-self: center; - max-width: var(--sp-l) 0; - color: var(--profile-foreground-color); + color: var(--color-foreground-primary); } .profile-img { - height: $sz-40; - width: $sz-40; + block-size: $sz-40; + inline-size: $sz-40; border-radius: $br-circle; } @@ -247,14 +260,14 @@ inset-inline-start: var(--sp-s); inset-block-end: px2rem(72); // 72 is the height of the profile button - min-width: calc(100% - var(--sp-s)); + min-inline-size: calc(100% - var(--sp-s)); // TODO ADD animation fadeInUp } .profile-dropdown-item { @extend %menu-item-base; - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); block-size: $sz-40; margin-block-end: var(--sp-xs); @@ -262,29 +275,25 @@ padding-inline-start: var(--sp-l); &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); .open-arrow { - --icon-stroke-color: var(--menu-foreground-color-hover); + --icon-stroke-color: var(--color-foreground-primary); } } } .profile-dropdown-item .open-arrow { - @include deprecated.flex-center; -} - -.profile-dropdown-item .open-arrow svg { - @extend %button-icon; - - stroke: var(--icon-foreground); + display: flex; + justify-content: center; + align-items: center; } .sub-menu { @extend %menu-dropdown; - inset-inline-start: calc(deprecated.$s-292 + var(--sp-s)); - min-width: deprecated.$s-192; + inset-inline-start: calc(#{px2rem(292)} + var(--sp-s)); + min-inline-size: $sz-192; } // Each submenu is positioned via its bottom edge; the visual top lands @@ -293,11 +302,11 @@ // its top edge sits one row above Community — aligning with the // "Help & Learning" trigger row in the parent menu. .sub-menu.help-learning { - inset-block-end: deprecated.$s-120; + inset-block-end: $sz-120; } .sub-menu.community { - inset-block-end: deprecated.$s-120; + inset-block-end: $sz-120; } .sub-menu.about { @@ -306,7 +315,7 @@ .submenu-item { @extend %menu-item-base; - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); block-size: $sz-40; margin-block-end: var(--sp-xs); @@ -314,7 +323,7 @@ padding-inline: var(--sp-l); &:hover { - color: var(--menu-foreground-color-hover); + color: var(--color-foreground-primary); } &.checking { @@ -334,7 +343,7 @@ } .submenu-separator { - border-top: $b-1 solid var(--color-background-quaternary); + border-block-start: $b-1 solid var(--color-background-quaternary); margin: 0; } @@ -345,8 +354,8 @@ } .menu-version { - @include t.use-typography("code-font"); - @include deprecated.text-ellipsis; + @include use-typography("code-font"); + @include text-ellipsis; color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); @@ -354,8 +363,8 @@ } .profile-separator { - height: $sz-6; - border-top: $b-1 solid var(--color-background-quaternary); + block-size: $sz-6; + border-block-start: $b-1 solid var(--color-background-quaternary); } .item-with-icon { @@ -365,38 +374,6 @@ } .exit-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); -} - -.upgrade-plan-section { - @include deprecated.button-style; - - display: flex; - justify-content: space-between; - border: $b-1 solid var(--color-background-quaternary); - border-radius: var(--sp-s); - padding: var(--sp-m); - margin: var(--sp-m); + stroke-width: 1px; color: var(--color-foreground-secondary); - cursor: pointer; -} - -.penpot-free { - @include t.use-typography("body-medium"); - - display: flex; - flex-direction: column; - text-align: left; -} - -.no-limits { - @include t.use-typography("body-small"); -} - -.power-up { - @include t.use-typography("body-small"); - - color: var(--color-accent-tertiary); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c747726caa..57b5569932 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -356,6 +356,10 @@ msgstr "View full changelog" msgid "dashboard.check-updates.view-release-notes" msgstr "View release notes" +#: src/app/main/ui/dashboard/sidebar.cljs:228 +msgid "dashboard.clear-search" +msgstr "Clear search" + #: src/app/main/ui/dashboard/deleted.cljs:322 msgid "dashboard.clear-trash-button" msgstr "Clear trash" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index c95dafcebd..6d20e94fe9 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -362,6 +362,10 @@ msgstr "Ver changelog completo" msgid "dashboard.check-updates.view-release-notes" msgstr "Ver notas de la versión" +#: src/app/main/ui/dashboard/sidebar.cljs:228 +msgid "dashboard.clear-search" +msgstr "Borrar búsqueda" + #: src/app/main/ui/dashboard/deleted.cljs:322 msgid "dashboard.clear-trash-button" msgstr "Vaciar papelera" From 8800ff4bc9b1179650399cfa3b6c90157e4265cd Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 11:41:23 +0100 Subject: [PATCH 168/325] :recycle: Use DS buttons and icons in the assets tab (#12559) * :recycle: Use DS buttons and icons in the assets tab - "Add library" and "Manage library" become DS primary and secondary buttons, full width with the label centered; they keep their test id. - The filter button stays a raw segment of the search bar; its legacy icon becomes the DS filter icon. Its opened state comes before the focus and hover rules, as the legacy placeholder did, so the focus border still shows. - The create/rename group modal gets a ghost DS icon button named "Close", placed on the tooltip wrapper, and DS cancel (secondary) and create/rename (primary) buttons instead of raw inputs. A visual test covers the add and manage library buttons, the filter at rest, on hover and opened, and the create group modal. AI-assisted-by: claude-opus-5-5 * :recycle: Move assets tab styles off the deprecated styles assets.scss no longer imports refactor/: its mixins, sizes, spacing and input / button icon color aliases resolve to DS values and tokens, with logical properties. The unused .sections-container, .section-item and .section-btn rules are deleted (the filter menu is the shared context menu). assets/groups.scss does the same and keeps refactor/ only for the modal placeholders (%modal-*) and %input-with-label. Its legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px): the group modal grows 2px. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 55 +++++++ .../app/main/ui/workspace/sidebar/assets.cljs | 22 ++- .../app/main/ui/workspace/sidebar/assets.scss | 149 +++++++----------- .../ui/workspace/sidebar/assets/groups.cljs | 30 ++-- .../ui/workspace/sidebar/assets/groups.scss | 49 +++--- 5 files changed, 159 insertions(+), 146 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 91dc893850..a9a4ef2bf3 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1106,3 +1106,58 @@ test.describe("Unpublish library modal", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Assets tab controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Add library button", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.clickAssets(); + + const add = workspace.sidebar.getByTestId("libraries"); + await expect(add).toBeVisible(); + await page.mouse.move(640, 600); + await expect(add).toHaveScreenshot(shot); + await add.hover(); + await expect(add).toHaveScreenshot(shot); + }); + + test("Libraries, filter and group modal", async ({ page }) => { + const workspace = new WorkspacePage(page); + const { fileId, pageId } = await setupFileWithAssets(workspace); + await workspace.goToWorkspace({ fileId, pageId }); + await workspace.clickAssets(); + + const header = workspace.sidebar + .getByTestId("libraries") + .locator("xpath=.."); + await expect(workspace.sidebar.getByTestId("libraries")).toBeVisible(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot(shot); + + await workspace.sidebar.getByTestId("libraries").hover(); + await expect(header).toHaveScreenshot(shot); + + const filter = workspace.sidebar.getByTitle("Filter"); + await filter.hover(); + await expect(header).toHaveScreenshot(shot); + + await filter.click(); + await page.mouse.move(640, 600); + await expect(header).toHaveScreenshot(shot); + await page.keyboard.press("Escape"); + + await workspace.sidebar.getByRole("button", { name: "Colors" }).click(); + await workspace.sidebar + .locator("[class*='colors'] [class*='asset-list-item']") + .first() + .click({ button: "right" }); + await page.getByText("Group", { exact: true }).click(); + const modal = page.locator("[class*='groups__modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 9650b235f3..ff5b91e7ef 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -18,8 +18,9 @@ [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.workspace.sidebar.assets.common :as cmm] [app.main.ui.workspace.sidebar.assets.file-library :refer [file-library*]] [app.main.ui.workspace.sidebar.scroll :as sc] @@ -201,14 +202,18 @@ [:div {:class (stl/css :assets-header)} (when-not ^boolean read-only? (if (and (= num-libs 1) (empty? components) (not shared?)) - [:button {:class (stl/css :add-library-button) - :on-click show-libraries-dialog - :data-testid "libraries"} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :libraries-button) + :on-click show-libraries-dialog + :data-testid "libraries"} (tr "workspace.assets.add-library")] - [:button {:class (stl/css :libraries-button) - :on-click show-libraries-dialog - :data-testid "libraries"} + [:> button* {:variant "secondary" + :type "button" + :class (stl/css :libraries-button) + :on-click show-libraries-dialog + :data-testid "libraries"} (tr "workspace.assets.manage-library")])) @@ -221,7 +226,8 @@ :title (tr "workspace.assets.filter") :class (stl/css-case :section-button true :opened menu-open?)} - deprecated-icon/filter-icon]] + [:> icon* {:icon-id i/filter + :class (stl/css :section-icon)}]]] [:> context-menu* {:on-close on-menu-close diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index 716d0682b8..afccd3404d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -4,141 +4,98 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .assets-bar { display: grid; grid-auto-rows: max-content; // TODO: ugly hack :( Fix this! we shouldn't be hardcoding this height - height: calc(100vh - deprecated.$s-92); + block-size: calc(100vh - #{px2rem(92)}); scrollbar-gutter: stable; overflow-y: auto; - padding-top: deprecated.$s-8; + padding-block-start: var(--sp-s); } +// A DS button keeps its label at the start when it grows. .libraries-button { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - gap: deprecated.$s-2; - height: deprecated.$s-32; - width: 100%; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$s-8; - - &:hover { - background-color: var(--button-secondary-background-color-hover); - color: var(--button-secondary-foreground-color-hover); - border: deprecated.$s-1 solid var(--button-secondary-border-color-hover); - } - - &:focus { - background-color: var(--button-secondary-background-color-focus); - color: var(--button-secondary-foreground-color-focus); - border: deprecated.$s-1 solid var(--button-secondary-border-color-focus); - } -} - -.add-library-button { - @extend %button-primary; - @include deprecated.uppercase-title-typography; - - gap: deprecated.$s-2; - height: deprecated.$s-32; - width: 100%; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$s-8; + inline-size: 100%; + margin-block-end: var(--sp-xs); + justify-content: center; } .section-button { - @include deprecated.flex-center; - @include deprecated.button-style; - - height: deprecated.$s-32; - width: deprecated.$s-32; + display: flex; + justify-content: center; + align-items: center; + background: none; + cursor: pointer; + block-size: $sz-32; + inline-size: $sz-32; margin: 0; - border: deprecated.$s-1 solid var(--input-border-color-rest); - border-radius: deprecated.$br-8 deprecated.$br-2 deprecated.$br-2 deprecated.$br-8; - background-color: var(--input-background-color-rest); + border: $b-1 solid var(--color-background-tertiary); + border-radius: $br-8 px2rem(2) px2rem(2) $br-8; + background-color: var(--color-background-tertiary); - svg { - height: deprecated.$s-16; - width: deprecated.$s-16; - stroke: var(--icon-foreground); + .section-icon { + flex-shrink: 0; + stroke-width: 1px; + color: var(--color-foreground-secondary); + } + + // Before :focus and :hover, as the legacy placeholder was: the focus + // border wins over the opened state. + &.opened { + outline: none; + border-color: var(--color-background-quaternary); + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); + + .section-icon { + color: var(--color-accent-primary); + } } &:focus { - border: deprecated.$s-1 solid var(--input-border-color-focus); + border: $b-1 solid var(--color-accent-primary); outline: 0; - background-color: var(--input-background-color-focus); - color: var(--input-foreground-color-focus); + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); - svg { - background-color: var(--input-background-color-focus); + .section-icon { + background-color: var(--color-background-tertiary); } } &:hover { - border: deprecated.$s-1 solid var(--input-border-color-hover); - background-color: var(--input-background-color-hover); + border: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-quaternary); - svg { - background-color: var(--input-background-color-hover); - stroke: var(--button-foreground-hover); + .section-icon { + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); } &:focus { - border: deprecated.$s-1 solid var(--input-border-color-focus); + border: $b-1 solid var(--color-accent-primary); outline: 0; - background-color: var(--input-background-color-focus); - color: var(--input-foreground-color-focus); + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); - svg { - background-color: var(--input-background-color-focus); + .section-icon { + background-color: var(--color-background-tertiary); } } } - - &.opened { - @extend %button-icon-selected; - } -} - -.sections-container { - @include deprecated.menu-shadow; - @include deprecated.flex-column; - - position: absolute; - top: deprecated.$s-84; - left: deprecated.$s-12; - width: deprecated.$s-192; - padding: deprecated.$s-4; - border-radius: deprecated.$br-8; - background-color: var(--menu-background-color); - z-index: deprecated.$z-index-2; -} - -.section-item { - @include deprecated.body-small-typography; - - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: deprecated.$s-6; - border-radius: deprecated.$br-8; -} - -.section-btn { - @include deprecated.button-style; } .assets-header { - padding: 0 0 deprecated.$s-24 deprecated.$s-12; + padding: 0 0 var(--sp-xxl) var(--sp-m); } .search-wrapper { display: flex; - gap: deprecated.$s-4; + gap: var(--sp-xs); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs index b072139f1e..745491c625 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs @@ -14,9 +14,9 @@ [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.sidebar.assets.common :as cmm] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -159,8 +159,11 @@ (if create? (tr "workspace.assets.create-group") (tr "workspace.assets.rename-group"))] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:& fm/form {:form form :on-submit on-accept} @@ -172,16 +175,13 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input - {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] - [:input - {:type "button" - :class (stl/css-case :accept-btn true - :global/disabled (not (:valid @form))) - :disabled (not (:valid @form)) - :value (if create? (tr "labels.create") (tr "labels.rename")) - :on-click on-accept}]]]]])) + [:> button* {:variant "primary" + :type "button" + :disabled (not (:valid @form)) + :on-click on-accept} + (if create? (tr "labels.create") (tr "labels.rename"))]]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss index 003db6e756..2e752f499d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss @@ -4,10 +4,14 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor, and the input placeholder (%input-with-label). @use "refactor/common-refactor.scss" as deprecated; +@use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; .group-title-wrapper { - width: 100%; + inline-size: 100%; display: flex; justify-content: space-between; @@ -30,21 +34,21 @@ } .group-title { - padding-left: deprecated.$s-4; + padding-inline-start: var(--sp-xs); } .pre-path { - margin-left: deprecated.$s-2; + margin-inline-start: var(--sp-xxs); text-transform: initial; - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .path { - @include deprecated.text-ellipsis; + @include text-ellipsis; - margin-left: deprecated.$s-2; + margin-inline-start: var(--sp-xxs); text-transform: initial; - color: var(--title-foreground-color-hover); + color: var(--color-foreground-primary); } .modal-overlay { @@ -56,44 +60,35 @@ } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .input-wrapper { @extend %input-with-label; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - margin-bottom: deprecated.$s-8; + margin-block-end: var(--sp-s); } .action-buttons { @extend %modal-action-btns; } - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} From 112e6dedd7b10e5818d31f7ce6302f85d9168e26 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 11:41:36 +0100 Subject: [PATCH 169/325] :recycle: Use DS icons in the copy button (#12558) * :recycle: Use DS icons in the copy button The shared copy button (Inspect attribute rows and code blocks, share link, integrations) draws its clipboard and tick icons with the DS icons at the small size, colored through the color property. The button itself stays: it wraps whole rows. The icons are the same drawings, so nothing changes on screen. A visual test covers an Inspect computed attribute row and the code block copy button, at rest, on hover and after copying. AI-assisted-by: claude-opus-5-5 * :recycle: Move copy button styles off the deprecated styles The copy button stylesheet no longer imports refactor/: the button-style, flex-center and copy-wrapper-base mixins are inlined with DS values, the tertiary button and icon color aliases resolve to their DS tokens, and physical properties become logical ones. No visual change (whole viewer visual spec compared; the code tab syntax highlighting loads late on a cold first run, with either stylesheet). AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-viewer.spec.js | 47 ++++++++ .../app/main/ui/components/copy_button.cljs | 8 +- .../app/main/ui/components/copy_button.scss | 102 +++++++++--------- 3 files changed, 105 insertions(+), 52 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 80ed3f7d52..5dac8b7377 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -395,3 +395,50 @@ test.describe("Interactions menu", () => { }); }); }); + +test.describe("Copy buttons", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + const openInspectTab = async (page, name) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithComments(); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + await viewerPage.showCode(); + await page.getByRole("combobox").filter({ hasText: "Styles" }).click(); + await page.getByRole("option", { name }).click(); + }; + + test("Computed attribute rows", async ({ page }) => { + await openInspectTab(page, "Computed"); + const row = page.locator("[class*='copy-wrapper']").first(); + await expect(row).toBeVisible(); + await page.mouse.move(10, 700); + await expect(row).toHaveScreenshot(shot); + + await row.hover(); + await expect(row).toHaveScreenshot(shot); + + await row.click(); + await expect(row).toHaveAttribute("data-active", "true"); + await expect(row).toHaveScreenshot(shot); + }); + + test("Code block copy button", async ({ page }) => { + await openInspectTab(page, "Code"); + const copy = page.locator("[class*='css-copy-btn']"); + await expect(copy).toBeVisible(); + await page.mouse.move(10, 700); + await expect(copy).toHaveScreenshot(shot); + + await copy.hover(); + await expect(copy).toHaveScreenshot(shot); + + await copy.click(); + await expect(copy).toHaveAttribute("data-active", "true"); + await expect(copy).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/components/copy_button.cljs b/frontend/src/app/main/ui/components/copy_button.cljs index b3d5c0f1f6..a4db5ddc65 100644 --- a/frontend/src/app/main/ui/components/copy_button.cljs +++ b/frontend/src/app/main/ui/components/copy_button.cljs @@ -9,7 +9,7 @@ (:require [app.common.data.macros :as dm] [app.main.data.event :as-alias ev] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -43,6 +43,6 @@ :on-click on-click} children [:span {:class (stl/css :icon-btn)} - (if active? - deprecated-icon/tick - deprecated-icon/clipboard)]])) + [:> icon* {:icon-id (if active? i/tick i/clipboard) + :size "s" + :class (stl/css :icon)}]]])) diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 3173698c92..8ae7cbf664 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -4,89 +4,95 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .copy-button { - @include deprecated.button-style; - - width: 100%; - height: deprecated.$s-32; - border: deprecated.$s-1 solid transparent; - border-radius: deprecated.$br-8; + border: $b-1 solid transparent; + background: none; + cursor: pointer; + inline-size: 100%; + block-size: $sz-32; + border-radius: $br-8; background-color: transparent; box-sizing: border-box; .icon-btn { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-32; + min-inline-size: $sz-28; + inline-size: $sz-28; - height: deprecated.$s-32; - min-width: deprecated.$s-28; - width: deprecated.$s-28; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); + .icon { + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-secondary); } } &:hover { background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); - border: deprecated.$s-1 solid var(--color-background-tertiary); + border: $b-1 solid var(--color-background-tertiary); - .icon-btn { - svg { - stroke: var(--button-tertiary-foreground-color-active); - } + .icon-btn .icon { + color: var(--color-accent-primary); } } &:focus, &:focus-visible { outline: none; - border: deprecated.$s-1 solid var(--button-tertiary-border-color-focus); + border: $b-1 solid var(--color-accent-primary); background-color: transparent; - color: var(--button-tertiary-foreground-color-focus); + color: var(--color-foreground-primary); - .icon-btn svg { - stroke: var(--button-tertiary-foreground-color-active); + .icon-btn .icon { + color: var(--color-accent-primary); } } } .copy-wrapper { - @include deprecated.button-style; - @include deprecated.copy-wrapper-base; - - width: 100%; - height: fit-content; - text-align: left; - border: deprecated.$s-1 solid transparent; + position: relative; + min-block-size: $sz-32; + inline-size: 100%; + block-size: fit-content; + padding: px2rem(7) 0 px2rem(7) px2rem(7); + border: $b-1 solid transparent; + border-radius: $br-8; + background: none; + box-sizing: border-box; + cursor: pointer; + text-align: start; .icon-btn { - @include deprecated.flex-center; - + display: flex; + justify-content: center; + align-items: center; position: absolute; - top: 0; - right: 0; - height: deprecated.$s-32; - width: deprecated.$s-28; + inset-block-start: 0; + inset-inline-end: 0; + block-size: $sz-32; + inline-size: $sz-28; - svg { - @extend %button-icon-small; - - stroke: var(--button-tertiary-foreground-color-focus); + .icon { + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-primary); display: none; } } &:hover { - background-color: var(--button-tertiary-background-color-focus); - color: var(--button-tertiary-foreground-color-focus); - border: deprecated.$s-1 solid var(--button-tertiary-background-color-focus); + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); + border: $b-1 solid var(--color-background-tertiary); - .icon-btn svg { + .icon-btn .icon { display: flex; } } @@ -94,8 +100,8 @@ &:focus, &:focus-visible { outline: none; - border: deprecated.$s-1 solid var(--button-tertiary-border-color-focus); + border: $b-1 solid var(--color-accent-primary); background-color: transparent; - color: var(--button-tertiary-foreground-color-focus); + color: var(--color-foreground-primary); } } From deb577124806303e6bf6fa05c5b2b920b628a159 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 27 Oct 2026 12:05:30 +0100 Subject: [PATCH 170/325] :sparkles: Use WASM for SVG markup in code inspect and copy (#12567) (#12608) When render-wasm is active, generate SVG for the Code inspector and Copy as SVG from the WASM export path instead of classic SVG codegen. Add multi-root render_shapes_svg so multi-select stays one document. Closes #12567 --- .../app/main/data/workspace/clipboard.cljs | 6 +- frontend/src/app/main/ui/inspect/code.cljs | 11 ++- frontend/src/app/render_wasm/api.cljs | 25 ++++++ .../app/util/code_gen/markup_svg_wasm.cljs | 56 ++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + .../util/code_gen_markup_svg_wasm_test.cljs | 67 ++++++++++++++ render-wasm/src/main.rs | 22 +++++ render-wasm/src/render/svg/fixtures.rs | 14 ++- render-wasm/src/render/svg/mod.rs | 89 ++++++++++++++++++- render-wasm/src/render/svg/tests.rs | 69 ++++++++++++++ render-wasm/src/state.rs | 4 + 11 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 frontend/src/app/util/code_gen/markup_svg_wasm.cljs create mode 100644 frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 37f7ef80b4..877e535f75 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -56,6 +56,7 @@ [app.render-wasm.text-paste :as text-paste] [app.util.clipboard :as clipboard] [app.util.code-gen.markup-svg :as svg] + [app.util.code-gen.markup-svg-wasm :as svg.wasm] [app.util.code-gen.style-css :as css] [app.util.globals :as ug] [app.util.http :as http] @@ -392,7 +393,10 @@ (gsh/translate-to-frame % (get objects parent-frame-id))) shapes (mapv maybe-translate selected) - svg-formatted (svg/generate-formatted-markup objects shapes)] + ;; With render-wasm active, never call classic SVG codegen. + svg-formatted (if (features/active-feature? state "render-wasm/v1") + (svg.wasm/get-formatted-svg-markup (mapv :id shapes)) + (svg/generate-formatted-markup objects shapes))] (-> (clipboard/to-clipboard-multi {"image/svg+xml" svg-formatted "text/plain" svg-formatted}) diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index b96759932a..d7a170a67e 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -14,6 +14,7 @@ [app.common.types.shape-tree :as ctst] [app.config :as cfg] [app.main.data.event :as ev] + [app.main.features :as features] [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] @@ -28,6 +29,7 @@ [app.util.clipboard :as clipboard] [app.util.code-beautify :as cb] [app.util.code-gen :as cg] + [app.util.code-gen.markup-svg-wasm :as svg.wasm] [app.util.dom :as dom] [app.util.http :as http] [app.util.i18n :refer [tr]] @@ -116,6 +118,9 @@ collapsed-css? (contains? @collapsed* :css) collapsed-markup? (contains? @collapsed* :markup) + ;; With render-wasm active, never use classic SVG codegen for SVG markup. + wasm-svg-markup? (features/use-feature "render-wasm/v1") + objects (use-objects from) shapes @@ -151,9 +156,11 @@ markup-code (mf/use-memo - (mf/deps markup-type shapes images-data) + (mf/deps markup-type shapes images-data wasm-svg-markup?) (fn [] - (cg/generate-formatted-markup-code objects markup-type shapes))) + (if (and (= markup-type "svg") wasm-svg-markup?) + (svg.wasm/get-formatted-svg-markup (mapv :id shapes)) + (cg/generate-formatted-markup-code objects markup-type shapes)))) on-markup-copied (mf/use-fn diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f5af3275dd..8a4ad7d427 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -3073,6 +3073,31 @@ (mem/free) result))) +(defn render-shapes-svg + "Renders the given shape ids into one SVG document (joined viewBox). + IDs are written to the transfer buffer (same layout as `render-shapes-pdf`)." + [shape-ids scale] + (when (and (initialized?) (seq shape-ids)) + (let [ids (vec shape-ids) + size (mem/get-alloc-size ids UUID-U8-SIZE) + heap (mem/get-heap-u32) + offset (mem/alloc->offset-32 size) + + _ (reduce (fn [offset id] + (mem.h32/write-uuid offset heap id)) + offset + ids) + + result-offset + (h/call wasm/internal-module "_render_shapes_svg" scale) + + heapu8 (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 result-offset)) + result (dr/read-image-bytes heapu8 (+ result-offset 4) length)] + (mem/free) + result))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/util/code_gen/markup_svg_wasm.cljs b/frontend/src/app/util/code_gen/markup_svg_wasm.cljs new file mode 100644 index 0000000000..4342ced0b6 --- /dev/null +++ b/frontend/src/app/util/code_gen/markup_svg_wasm.cljs @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.util.code-gen.markup-svg-wasm + "SVG markup via the render-wasm export path (inspect / copy-as-svg). + Callers must gate on `render-wasm/v1` and must not fall back to classic + SVG codegen while that feature is on." + (:require + [app.render-wasm.api :as wasm.api] + [app.util.code-beautify :as cb] + [cuerdas.core :as str])) + +(defn- decode-utf8 + [bytes] + (when bytes + (.decode (js/TextDecoder.) bytes))) + +(defn get-svg-markup + "UTF-8 SVG string for the given shape ids via the main-thread WASM module. + Empty string when WASM is not ready, ids are empty, or export throws." + [shape-ids] + (let [ids (vec shape-ids)] + (cond + (empty? ids) + "" + + (not (wasm.api/initialized?)) + "" + + :else + (try + (or (decode-utf8 + (if (= 1 (count ids)) + (wasm.api/render-shape-svg (first ids) 1) + (wasm.api/render-shapes-svg ids 1))) + "") + ;; Missing shape / export failure is a WASM CriticalError (:panic). + ;; Letting it reach the global handler would show Internal Error and + ;; block the workspace; the user can still work without this markup, so + ;; log and return empty instead (same pattern as other non-fatal WASM UI paths). + (catch :default cause + (js/console.error "WASM SVG markup failed:" cause) + ""))))) + +(defn get-formatted-svg-markup + "Beautified SVG markup for inspect / copy-as-svg. + Skips beautify when markup is empty so inspect/copy stay safe without + js-beautify (and avoid a no-op format call)." + [shape-ids] + (let [markup (get-svg-markup shape-ids)] + (if (str/empty? markup) + "" + (cb/format-code markup "svg")))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f2585704e8..5e18876e88 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -151,6 +151,7 @@ [frontend-tests.util-text-editor-test] [frontend-tests.util-webapi-test] [frontend-tests.util-zip-test] + [frontend-tests.util.code-gen-markup-svg-wasm-test] [frontend-tests.util.dom.dnd-test] [frontend-tests.worker-snap-test] [frontend-tests.worker.exports-test] @@ -315,6 +316,7 @@ 'frontend-tests.util-text-clipboard-test 'frontend-tests.util-text-editor-test 'frontend-tests.util-webapi-test + 'frontend-tests.util.code-gen-markup-svg-wasm-test 'frontend-tests.util.dom.dnd-test 'frontend-tests.util-zip-test 'frontend-tests.worker-snap-test]) diff --git a/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs b/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs new file mode 100644 index 0000000000..62df62eb59 --- /dev/null +++ b/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs @@ -0,0 +1,67 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.util.code-gen-markup-svg-wasm-test + "Unit tests for WASM SVG markup helpers used by Code inspect and Copy as SVG." + (:require + [app.common.uuid :as uuid] + [app.render-wasm.api :as wasm.api] + [app.util.code-gen.markup-svg-wasm :as svg.wasm] + [cljs.test :as t :include-macros true])) + +(defn- utf8-bytes + [s] + (.encode (js/TextEncoder.) s)) + +(t/deftest get-svg-markup-empty-ids + (t/is (= "" (svg.wasm/get-svg-markup [])))) + +(t/deftest get-svg-markup-not-initialized + (with-redefs [wasm.api/initialized? (constantly false)] + (t/is (= "" (svg.wasm/get-svg-markup [(uuid/next)]))))) + +(t/deftest get-svg-markup-single-shape + (let [id (uuid/next) + called (atom nil)] + (with-redefs [wasm.api/initialized? (constantly true) + wasm.api/render-shape-svg + (fn [shape-id scale] + (reset! called [shape-id scale]) + (utf8-bytes ""))] + (t/is (= "" (svg.wasm/get-svg-markup [id]))) + (t/is (= [id 1] @called))))) + +(t/deftest get-svg-markup-multi-shapes-one-document + (let [ids [(uuid/next) (uuid/next)] + called (atom nil)] + (with-redefs [wasm.api/initialized? (constantly true) + wasm.api/render-shapes-svg + (fn [shape-ids scale] + (reset! called [shape-ids scale]) + (utf8-bytes ""))] + (t/is (= "" (svg.wasm/get-svg-markup ids))) + (t/is (= [ids 1] @called)) + (t/is (= 1 (count (re-seq #" Result<*mut u8> { + let bytes = mem::bytes(); + let ids: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + with_state!(state, { + let data = state.render_shapes_svg(&ids, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + /// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]` /// (LE), same layout as `render_shape_pixels`. `format` selects the encoder: /// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`). diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index a33d5d258e..a112752b6b 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -15,7 +15,7 @@ use crate::state::ShapesPool; use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; -use super::render_tree_to_svg; +use super::{render_tree_to_svg, render_trees_to_svg}; /// Font URL referenced in exported SVG `@font-face` rules. pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; @@ -607,6 +607,18 @@ pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { render_with(pool, root, |_resources| {}) } +/// Multi-root SVG export for tests (one document, joined viewBox). +pub(super) fn render_multi(pool: &ShapesPool, roots: &[Uuid]) -> String { + let _serial = svg_export_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + register_test_font_urls(&mut resources.fonts); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let bytes = render_trees_to_svg(&mut resources, roots, pool, 1.0).expect("svg multi export"); + String::from_utf8(bytes).expect("utf8 svg") +} + /// SVG export installs a process-wide resources pointer; serialize tests that /// call this so parallel rustc threads do not race / SIGSEGV. fn svg_export_test_lock() -> &'static Mutex<()> { diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index ae4fdd3c08..997a68e71c 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -104,6 +104,88 @@ pub(crate) fn render_tree_to_svg( collect_font_aliases(tree, id, &mut aliases); let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + Ok(emit_svg_document(page_w, page_h, &font_css, &defs, &body)) +} + +/// Multi-root SVG export: one document whose viewBox covers every visible root, +/// with relative placement preserved (inspect / copy-as-svg multi-select). +pub fn render_to_svg_multi( + shared: &mut RenderResources, + ids: &[Uuid], + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + render_trees_to_svg(shared, ids, tree, scale) +} + +/// Core multi-root SVG export for headless tests and the WASM bridge. +pub(crate) fn render_trees_to_svg( + shared: &mut RenderResources, + ids: &[Uuid], + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + if ids.is_empty() { + return Ok(emit_svg_document(0.0, 0.0, "", "", "")); + } + + let mut joined: Option = None; + let mut roots: Vec = Vec::with_capacity(ids.len()); + for id in ids { + let Some(shape) = tree.get(id) else { + continue; + }; + if shape.hidden { + continue; + } + let bounds = svg_page_bounds(shape, tree, scale); + match &mut joined { + None => joined = Some(bounds), + Some(acc) => { + acc.join(bounds); + } + } + roots.push(*id); + } + + // Missing or hidden-only roots: empty document (same as empty `ids`), not + // CriticalError — inspect/copy must not crash the host when nothing is drawable. + let Some(bounds) = joined else { + return Ok(emit_svg_document(0.0, 0.0, "", "", "")); + }; + + let page_w = bounds.width() * scale; + let page_h = bounds.height() * scale; + let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + let tx = -bounds.left(); + let ty = -bounds.top(); + + let mut builder = SvgLayerCanvas::new(scale, rect, tx, ty); + for id in &roots { + if let Some(shape) = tree.get(id) { + builder.nested_fills.push(shape.inherited_fills(tree)); + } + render_tree(&mut builder, shared, id, tree, scale)?; + builder.nested_fills.pop(); + } + builder.flush(); + + let mut aliases = HashSet::new(); + for id in &roots { + collect_font_aliases(tree, id, &mut aliases); + } + let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + + Ok(emit_svg_document( + page_w, + page_h, + &font_css, + &builder.defs, + &builder.out, + )) +} + +fn emit_svg_document(page_w: f32, page_h: f32, font_css: &str, defs: &str, body: &str) -> Vec { let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); out.push_str("\n"); out.push_str(&format!( @@ -118,14 +200,13 @@ pub(crate) fn render_tree_to_svg( "" )); } - out.push_str(&defs); + out.push_str(defs); out.push_str(""); } - out.push_str(&body); + out.push_str(body); out.push_str(""); - - Ok(out.into_bytes()) + out.into_bytes() } mod background_blur; diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 87eeb29fb4..490d5ef27a 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -58,6 +58,75 @@ fn exports_a_solid_rect() { insta::assert_snapshot!(render(&pool, id)); } +#[test] +fn exports_multiple_roots_in_one_svg_with_relative_placement() { + let mut pool = ShapesPool::new(); + let left = uid(1); + let right = uid(2); + add_solid_rect( + &mut pool, + left, + Uuid::nil(), + (0.0, 0.0, 50.0, 50.0), + skia::Color::from_rgb(255, 0, 0), + ); + add_solid_rect( + &mut pool, + right, + Uuid::nil(), + (100.0, 0.0, 150.0, 50.0), + skia::Color::from_rgb(0, 0, 255), + ); + + let svg = render_multi(&pool, &[left, right]); + assert_eq!( + svg.matches(": {svg}" + ); + assert_eq!( + svg.matches("").count(), + 1, + "multi-root export must emit one : {svg}" + ); + // Joined page is 150×50; both fills present and the right rect keeps x=100. + assert!( + svg.contains("viewBox=\"0 0 150 50\""), + "joined viewBox should span both rects: {svg}" + ); + assert!(svg.contains("fill=\"red\""), "missing left red fill: {svg}"); + assert!( + svg.contains("fill=\"blue\"") && svg.contains("x=\"100\""), + "missing right blue fill at x=100: {svg}" + ); +} + +#[test] +fn exports_multi_root_all_hidden_or_missing_as_empty_svg() { + let mut pool = ShapesPool::new(); + let hidden = uid(1); + add_solid_rect( + &mut pool, + hidden, + Uuid::nil(), + (0.0, 0.0, 50.0, 50.0), + skia::Color::from_rgb(255, 0, 0), + ); + pool.get_mut(&hidden).unwrap().set_hidden(true); + + let missing = uid(99); + let svg = render_multi(&pool, &[hidden, missing]); + assert_eq!(svg.matches(" Result> { + crate::render::svg::render_to_svg_multi(get_resources(), ids, &self.shapes, scale) + } + /// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to /// `format` on a CPU raster surface, no GPU/WebGL. pub fn render_shape_raster( From 29e79edc667417bd92489302aeeb29dbe3b58707 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 12:19:47 +0100 Subject: [PATCH 171/325] :recycle: Use DS buttons and icons in the dashboard team pages (#12531) * :recycle: Use DS buttons and icons in the dashboard team pages The team pages (members, invitations, webhooks and settings) and their modals move off the deprecated icons and raw buttons: - The member and webhook row menus become ghost DS icon buttons named "Options", with aria-expanded. - The member and invitation role selectors become buttons with aria-haspopup and aria-expanded, and use the DS arrow icon. They were divs; one carried a role and an id repeated on every row. - The four modal close buttons become ghost DS icon buttons named "Close"; the class that places them goes on the tooltip wrapper. - "Create webhook" becomes a DS primary button. - The webhook status, team image and settings icons use the DS icons. The invitation checkbox tick stays until the checkbox migration. A visual test covers the role and row menus, the resend and webhook modals and the team image overlay. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard team styles to logical properties and DS tokens The remaining legacy color aliases resolve to their DS tokens, physical offsets and margins become logical properties, and the DS typography module is used without a namespace. Four selectors that team.cljs does not use are commented out with a TODO. refactor/ stays only for component-family placeholders: %input-checkbox and %checkbox-icon (checkboxes) and %modal-* (modals). The shared dashboard header styles (common-dashboard) are used by ten dashboard modules and move in their own change. No visual change. AI-assisted-by: claude-opus-5-5 * :bug: Open the team row menus below their button The member and webhook row menus were placed from the bottom of their cell with a fixed offset, so their position depended on the menu height and the button height. The two-item webhook menu covered its button, the member menu covered the lower part of it, and the taller DS icon button moved both 3px down. They now open 4px below the button. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard team pages The SCSS refactor left four rules commented out with a TODO because team.cljs does not use them. CSS modules scope the classes to this module and no other source uses them, so they go: .dashboard-top-cta, .error-notification, .empty-invitations-buttons and .invitations-dropdown. No visual change. AI-assisted-by: claude-opus-5-5 * :bug: Find the member role selector as a button in the dashboard test The "Bug 9443" test looked for the member role selector as a combobox. The selector is now a button with aria-haspopup, so the test failed in CI. It now looks for a button named "Admin", and checks that the owner has no such button. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/dashboard.spec.js | 4 +- .../ui/visual-specs/visual-dashboard.spec.js | 87 ++++++++ frontend/src/app/main/ui/dashboard/team.cljs | 126 +++++------ frontend/src/app/main/ui/dashboard/team.scss | 203 +++++++----------- 4 files changed, 232 insertions(+), 188 deletions(-) diff --git a/frontend/playwright/ui/specs/dashboard.spec.js b/frontend/playwright/ui/specs/dashboard.spec.js index 044ed5226a..d20b90cfd4 100644 --- a/frontend/playwright/ui/specs/dashboard.spec.js +++ b/frontend/playwright/ui/specs/dashboard.spec.js @@ -131,9 +131,9 @@ test("Bug 9443, Admin can not demote owner", async ({ page }) => { await dashboardPage.goToSecondTeamMembersSection(); await expect(page.getByRole("heading", { name: "Members" })).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Admin" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Admin" })).toBeVisible(); await expect(page.getByText("Owner")).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Owner" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Owner" })).toHaveCount(0); }); test("Bug 9927, Don't show the banner to invite team members if the user has dismissed it", async ({ diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 320d855e64..17d3ba0faf 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -514,3 +514,90 @@ test.describe("Dashboard sidebar controls", () => { ).toHaveScreenshot(shot); }); }); + +test.describe("Team pages controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + // The row menu button: a raw button with the menu-btn class before the DS + // migration, a DS icon button named "Options" after it. + const openRowMenu = (page) => { + const rows = page.locator("[class*='table-rows']"); + return rows + .locator("[class*='menu-btn']") + .or(rows.getByRole("button", { name: "Options" })) + .first() + .click(); + }; + + test("Members role and actions menus", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Han Solo owns the team; the logged-in user is an admin. + await dashboardPage.mockRPC( + "get-team-members?team-id=*", + "dashboard/get-team-members-admin.json", + ); + await dashboardPage.goToSecondTeamMembersSection(); + await expect(page.getByText("role")).toBeVisible(); + + await page.locator("[class*='has-priv']").first().click(); + await expect(page.locator("[class*='roles-dropdown']")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.keyboard.press("Escape"); + await page.mouse.click(10, 700); + await openRowMenu(page); + await expect(page.locator("[class*='actions-dropdown']")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + }); + + test("Invitations role menu and resend modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.setupTeamInvitations(); + await dashboardPage.goToSecondTeamInvitationsSection(); + await expect(page.getByText("test1@mail.com")).toBeVisible(); + + await page.locator("[class*='has-priv']").first().click(); + await expect(page.locator("[class*='roles-dropdown']")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.mouse.click(10, 700); + await page.getByText("test1@mail.com").click(); + await page.getByRole("button", { name: "Resend invitation" }).click(); + await expect(page.getByText("Resend invitations")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + }); + + test("Webhooks actions menu and modal", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.setupTeamWebhooks(); + await dashboardPage.goToSecondTeamWebhooksSection(); + await expect(page.getByText("https://www.google.com")).toBeVisible(); + + await openRowMenu(page); + await expect( + page.locator("[class*='webhook-actions-dropdown']"), + ).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.mouse.click(10, 700); + await page.getByText("Create webhook").click(); + await expect(page.getByText("Payload URL")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + }); + + test("Team settings image overlay", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.setupTeamSettings(); + await dashboardPage.goToSecondTeamSettingsSection(); + await expect(page.getByText("TEAM INFO")).toBeVisible(); + + await page.locator("[class*='team-icon']").hover(); + await expect(page.locator("[class*='team-icon']")).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index b84ed95f0b..8888c6d4e3 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -52,33 +52,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - -(def ^:private organization-menu-icon - (deprecated-icon/icon-xref :menu (stl/css :organization-menu-icon))) - -(def ^:private warning-icon - (deprecated-icon/icon-xref :msg-warning (stl/css :warning-icon))) - -(def ^:private success-icon - (deprecated-icon/icon-xref :msg-success (stl/css :success-icon))) - -(def ^:private image-icon - (deprecated-icon/icon-xref :img (stl/css :image-icon))) - -(def ^:private user-icon - (deprecated-icon/icon-xref :user (stl/css :user-icon))) - -(def ^:private document-icon - (deprecated-icon/icon-xref :document (stl/css :document-icon))) - -(def ^:private group-icon - (deprecated-icon/icon-xref :group (stl/css :group-icon))) - (mf/defc header* {::mf/wrap [mf/memo]} [{:keys [section team profile]}] @@ -323,8 +296,11 @@ [:div {:class (stl/css :modal-restricted-header)} [:h2 {:class (stl/css :modal-restricted-title)} (tr "modals.invite-restricted-members.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-restricted-content)} [:p (tr "modals.invite-restricted-members.description")] @@ -409,13 +385,14 @@ on-hide (mf/use-fn #(reset! show? false))] [:* (if (and can-change-rol not-superior (not (and is-you is-owner))) - [:div {:class (stl/css :rol-selector :has-priv) - :role "combobox" - :aria-labelledby "role-label-id" - :on-click on-show} - [:span {:class (stl/css :rol-label) - :id "role-label-id"} role] - arrow-icon] + [:button {:class (stl/css :rol-selector :has-priv) + :type "button" + :aria-haspopup "listbox" + :aria-expanded @show? + :on-click on-show} + [:span {:class (stl/css :rol-label)} role] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :arrow-icon)}]] [:div {:class (stl/css :rol-selector)} [:span {:class (stl/css :rol-label)} role]]) @@ -451,9 +428,12 @@ (when (or is-you? (and can-delete? (not (and is-owner? (not owner?))))) [:* - [:button {:class (stl/css :menu-btn) - :on-click on-show} - menu-icon] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded @show? + :icon i/menu + :on-click on-show}] [:& dropdown {:show @show? :on-close on-hide :dropdown-id (str "member-actions-" (:id member))} [:ul {:class (stl/css :actions-dropdown)} @@ -691,10 +671,14 @@ [:* (if (and can-invite (= status :pending)) - [:div {:class (stl/css :rol-selector :has-priv) - :on-click on-show} + [:button {:class (stl/css :rol-selector :has-priv) + :type "button" + :aria-haspopup "listbox" + :aria-expanded @show? + :on-click on-show} [:span {:class (stl/css :rol-label)} label] - arrow-icon] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :arrow-icon)}]] [:div {:class (stl/css :rol-selector)} [:span {:class (stl/css :rol-label)} label]]) @@ -872,8 +856,11 @@ (tr "dashboard.invitation-modal.title.delete-invitations") (tr "dashboard.invitation-modal.title.resend-invitations"))] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-invitation-content)} [:p @@ -1000,8 +987,11 @@ [:h2 {:class (stl/css :modal-select-organization-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] (when text [:div {:class (stl/css :modal-content :modal-select-organization-text)} text]) @@ -1380,8 +1370,11 @@ (tr "modals.edit-webhook.title") (tr "modals.create-webhook.title"))] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :fields-row)} @@ -1422,8 +1415,10 @@ (tr "labels.webhooks")] [:> i18n/tr-html* {:class (stl/css :hero-desc) :content (tr "dashboard.webhooks.description")}] - [:button {:class (stl/css :hero-btn) - :on-click #(st/emit! (modal/show :webhook {}))} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :hero-btn) + :on-click #(st/emit! (modal/show :webhook {}))} (tr "dashboard.webhooks.create")]]) (mf/defc webhook-actions* @@ -1434,9 +1429,12 @@ on-hide (mf/use-fn #(reset! show? false))] (if can-edit [:* - [:button {:class (stl/css :menu-btn) - :on-click on-show} - menu-icon] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded @show? + :icon i/menu + :on-click on-show}] [:& dropdown {:show @show? :on-close on-hide :dropdown-id "webhook-actions"} [:ul {:class (stl/css :webhook-actions-dropdown)} [:li {:on-click on-edit @@ -1502,8 +1500,10 @@ [:div {:class (stl/css :table-field :last-delivery) :title last-delivery-text} (if (nil? error-code) - success-icon - warning-icon)] + [:> icon* {:icon-id i/msg-success + :class (stl/css :success-icon)}] + [:> icon* {:icon-id i/msg-warning + :class (stl/css :warning-icon)}])] [:div {:class (stl/css :table-field :uri)} [:div (dm/str (:uri webhook))]] [:div {:class (stl/css :table-field :active)} @@ -1657,7 +1657,9 @@ (when can-edit [:button {:class (stl/css :update-overlay) :on-click on-image-click} - image-icon]) + [:> icon* {:icon-id i/img + :size "l" + :class (stl/css :image-icon)}]]) [:img {:class (stl/css :team-image) :src (cfg/resolve-team-photo-url team)}] (when can-edit @@ -1688,7 +1690,8 @@ :type "button" :class (stl/css-case :organization-options-btn (not show-organization-options-menu?) :organization-options-btn-open show-organization-options-menu?) :on-click on-show-options-click} - organization-menu-icon + [:> icon* {:icon-id i/menu + :class (stl/css :organization-menu-icon)}] [:& dropdown {:show show-organization-options-menu? :on-close close-organization-options-menu :dropdown-id "organization-options"} [:ul {:class (stl/css :organization-dropdown) @@ -1720,7 +1723,8 @@ (str (:name owner) " (" (tr "labels.owner") ")")]] [:div {:class (stl/css :block-content)} - user-icon + [:> icon* {:icon-id i/user + :class (stl/css :user-icon)}] [:span {:class (stl/css :block-text)} (tr "dashboard.num-of-members" (count members))]]] @@ -1729,12 +1733,14 @@ (tr "dashboard.team-projects")] [:div {:class (stl/css :block-content)} - group-icon + [:> icon* {:icon-id i/group + :class (stl/css :group-icon)}] [:span {:class (stl/css :block-text)} (tr "labels.num-of-projects" (i18n/c (dec (:projects stats))))]] [:div {:class (stl/css :block-content)} - document-icon + [:> icon* {:icon-id i/document + :class (stl/css :document-icon)}] [:span {:class (stl/css :block-text)} (tr "labels.num-of-files" (i18n/c (:files stats)))]]] diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 2a8b754496..b51bd9d071 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -4,10 +4,13 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the component-family placeholders: %input-checkbox +// and %checkbox-icon (checkboxes, #12380) and %modal-* (modals). The shared +// dashboard header (common-dashboard) moves in its own change. @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/mixins.scss" as *; @@ -44,7 +47,7 @@ } .block-label { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); color: var(--color-foreground-secondary); } @@ -75,11 +78,9 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; margin: 0 auto; - stroke: var(--color-foreground-secondary); + color: var(--color-foreground-secondary); } .team-icon { @@ -98,8 +99,8 @@ .team-image { position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; border-radius: 50%; inline-size: $sz-120; block-size: $sz-120; @@ -114,8 +115,8 @@ justify-content: center; align-items: center; position: absolute; - top: 0; - left: 0; + inset-block-start: 0; + inset-inline-start: 0; block-size: 100%; inline-size: 100%; z-index: var(--z-index-set); @@ -124,17 +125,8 @@ } .image-icon { - display: flex; - justify-content: center; - align-items: center; - block-size: $sz-16; - inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - min-inline-size: $sz-24; - min-block-size: $sz-24; - stroke: var(--color-foreground-primary); + color: var(--color-foreground-primary); } // TEAM MEMBERS PAGE @@ -150,11 +142,6 @@ scrollbar-gutter: stable; } -.dashboard-team-members.dashboard-top-cta { - flex-direction: column; - justify-content: flex-start; -} - .team-members { display: grid; grid-template-rows: auto 1fr; @@ -164,7 +151,7 @@ } .table-header { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); display: grid; align-items: center; @@ -184,7 +171,7 @@ inline-size: 100%; block-size: 100%; max-inline-size: $sz-1000; - margin-top: var(--sp-l); + margin-block-start: var(--sp-l); color: var(--color-foreground-secondary); } @@ -250,23 +237,23 @@ .member-name, .member-email { @include text-ellipsis; - @include t.use-typography("body-large"); + @include use-typography("body-large"); } .member-email { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } .you { color: var(--color-foreground-secondary); - margin-left: px2rem(6); + margin-inline-start: px2rem(6); } // ROL INFO .rol-selector { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); position: relative; display: grid; @@ -282,6 +269,9 @@ } .has-priv { + border: none; + color: inherit; + text-align: start; cursor: pointer; } @@ -302,13 +292,13 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - bottom: calc(-1 * px2rem(76)); + inset-block-end: calc(-1 * px2rem(76)); inline-size: fit-content; min-inline-size: $sz-160; } .rol-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -325,24 +315,6 @@ } // MEMBER ACTIONS -.menu-icon { - display: flex; - justify-content: center; - align-items: center; - block-size: $sz-16; - inline-size: $sz-16; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--color-foreground-primary); -} - -.menu-btn { - border: none; - background: none; - cursor: pointer; -} - .input-checkbox { // TODO: remove this extended class. @extend %input-checkbox; @@ -363,15 +335,17 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - bottom: calc(-1 * var(--sp-xxxl)); - right: 0; - left: unset; + + // Opens below the button, whatever the menu and button heights. + inset-block-start: calc(100% + var(--sp-xs)); + inset-inline-end: 0; + inset-inline-start: unset; inline-size: fit-content; min-inline-size: $sz-160; } .action-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -409,21 +383,17 @@ } .invitations-actions { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; justify-content: end; align-items: center; gap: var(--sp-l); - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); block-size: $sz-40; margin-block-end: px2rem(36); } -.error-notification { - margin-block-end: var(--sp-xxl); -} - .table-row-invitations { grid-template-columns: 43% 1fr $sz-88 var(--sp-xxl); align-items: center; @@ -431,7 +401,7 @@ .empty-invitations { inline-size: 100%; - margin-top: var(--sp-l); + margin-block-start: var(--sp-l); border: $b-1 solid var(--color-background-quaternary); border-radius: $br-8; color: var(--color-foreground-secondary); @@ -439,11 +409,6 @@ text-align: center; } -.empty-invitations-buttons { - inline-size: fit-content; - margin: auto; -} - .no-permission-text { max-inline-size: $sz-512; margin: auto; @@ -468,18 +433,13 @@ .field-email { @include text-ellipsis; - @include t.use-typography("body-large"); + @include use-typography("body-large"); display: flex; gap: var(--sp-l); align-items: center; } -.invitations-dropdown { - bottom: calc(-1 * px2rem(112)); - right: calc(-1 * var(--sp-xl)); -} - // WEBHOOKS SECTION .dashboard-team-webhooks { display: grid; @@ -515,7 +475,7 @@ } .webhooks-hero { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: grid; grid-template-rows: auto 1fr auto; @@ -527,24 +487,20 @@ } .hero-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); color: var(--color-foreground-primary); } .hero-desc { - @include t.use-typography("body-large"); + @include use-typography("body-large"); color: var(--color-foreground-secondary); - margin-bottom: 0; + margin-block-end: 0; max-inline-size: $sz-512; } .hero-btn { - // TODO: Remove this extended class using a DS component - @extend %button-primary; - - block-size: $sz-32; max-inline-size: $sz-512; } @@ -584,14 +540,16 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - right: calc(-1 * var(--sp-l)); - bottom: calc(-1 * $sz-40); + inset-inline-end: calc(-1 * var(--sp-l)); + + // Opens below the button, whatever the menu and button heights. + inset-block-start: calc(100% + var(--sp-xs)); inline-size: fit-content; min-inline-size: $sz-160; } .webhook-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -613,10 +571,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-accent-success); + color: var(--color-accent-success); } .warning-icon { @@ -625,10 +581,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-accent-warning); + color: var(--color-accent-warning); } // INVITE MEMBERS MODAL @@ -641,22 +595,22 @@ max-block-size: $sz-512; box-shadow: var(--el-shadow-dark); position: fixed; - top: px2rem(72); - right: var(--sp-m); - left: unset; + inset-block-start: px2rem(72); + inset-inline-end: var(--sp-m); + inset-inline-start: unset; inline-size: $sz-400; padding: var(--sp-xxxl); background-color: var(--color-background-primary); z-index: var(--z-index-set); &.hero { - top: px2rem(216); - right: var(--sp-xxxl); + inset-block-start: px2rem(216); + inset-inline-end: var(--sp-xxxl); } } .modal-team-container-workspace { - top: $sz-40; + inset-block-start: $sz-40; z-index: var(--z-index-set); } @@ -687,11 +641,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-foreground-secondary); - transform: rotate(90deg); + color: var(--color-foreground-secondary); } .action-buttons { @@ -711,12 +662,12 @@ justify-content: center; align-items: center; position: fixed; - left: 0; - top: 0; + inset-inline-start: 0; + inset-block-start: 0; block-size: 100%; inline-size: 100%; z-index: var(--z-index-set); - background-color: var(--overlay-color); + background-color: var(--color-overlay-default); } .modal-container { @@ -732,27 +683,29 @@ } .modal-header { - margin-bottom: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .modal-title { - @include t.use-typography("title-small"); + @include use-typography("title-small"); color: var(--color-foreground-primary); } +// Tooltip wrapper of the modal close buttons: it places them in the corner. .modal-close-btn { - // TODO remove extended class creating a modal component - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; flex-direction: column; gap: var(--sp-xxl); - margin-bottom: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .fields-row { @@ -762,7 +715,7 @@ } .select-title { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-primary); } @@ -790,7 +743,7 @@ display: flex; align-items: center; - @include t.use-typography("body-large"); + @include use-typography("body-large"); label { display: flex; @@ -815,7 +768,7 @@ span { @extend %checkbox-icon; - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } @@ -823,7 +776,7 @@ input { margin: 0; - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); } @@ -842,13 +795,13 @@ } .modal-invitation-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); color: var(--color-foreground-primary); } .modal-invitation-content { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); overflow: auto; color: var(--color-foreground-secondary); @@ -877,13 +830,13 @@ } .modal-restricted-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); color: var(--color-foreground-primary); } .modal-restricted-content { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: 0; } @@ -955,13 +908,13 @@ } .modal-select-organization-content { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } .modal-select-organization-info { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-direction: column; @@ -971,13 +924,13 @@ } .modal-select-organization-title { - @include t.use-typography("headline-large"); + @include use-typography("headline-large"); color: var(--color-foreground-primary); } .modal-select-organization-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } @@ -1023,10 +976,8 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--stroke-color); + color: var(--stroke-color); } .organization-dropdown { @@ -1042,13 +993,13 @@ background-color: var(--color-background-tertiary); border: $b-2 solid var(--color-background-quaternary); margin: 0; - top: var(--sp-xxxl); + inset-block-start: var(--sp-xxxl); inline-size: fit-content; min-inline-size: $sz-160; } .organization-dropdown-item { - @include t.use-typography("body-small"); + @include use-typography("body-small"); display: flex; align-items: center; @@ -1071,5 +1022,5 @@ } a { - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } From c26a67a8dde205c2eaf5948d7e5f4fd1468de603 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 14:07:42 +0100 Subject: [PATCH 172/325] :recycle: Use a DS button for previous in the onboarding questions (#12550) * :recycle: Use a DS button for previous in the onboarding questions The "Previous" button of the onboarding questions becomes a DS secondary button; it was a raw button with the deprecated modal cancel styles. It is a bit narrower and its label uses the DS typography. "Next" stays: it is the shared forms submit button. The "Other" option icon stays too: it belongs to the forms image radio buttons, which move with the radio buttons group. A visual test covers the step 2 buttons at rest and with "Previous" hovered. AI-assisted-by: claude-opus-5-5 * :recycle: Move onboarding questions styles closer to the DS The onboarding questions stylesheet resolves its legacy modal color aliases to DS tokens, uses logical properties and the DS typography module without a namespace. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. No visual change (checked on steps 1 and 2; only the shuffled options differ between runs). AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 28 +++++++++++++++ .../src/app/main/ui/onboarding/questions.cljs | 6 ++-- .../src/app/main/ui/onboarding/questions.scss | 36 +++++++++---------- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 17d3ba0faf..06e3cd2639 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -601,3 +601,31 @@ test.describe("Team pages controls", () => { await expect(page.locator("[class*='team-icon']")).toHaveScreenshot(shot); }); }); + +test.describe("Onboarding questions controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Previous and next buttons", async ({ page }) => { + await DashboardPage.mockConfigFlags(page, ["enable-onboarding"]); + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await expect( + page.getByRole("heading", { name: "Help us get to know you" }), + ).toBeVisible(); + + // Step 1, as in OnboardingPage.fillOnboardingInputsStep1. + await page.getByText("Personal", { exact: true }).click(); + await page.getByText("Select option").click(); + await page.getByText("Product Management").click(); + await page.getByRole("button", { name: "Next" }).click(); + + const previous = page.getByRole("button", { name: "Previous" }); + await expect(previous).toBeVisible(); + const actions = previous.locator("xpath=.."); + await page.mouse.move(10, 700); + await expect(actions).toHaveScreenshot(shot); + + await previous.hover(); + await expect(actions).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs index a3c19b32fc..2b65c4a2ee 100644 --- a/frontend/src/app/main/ui/onboarding/questions.cljs +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -15,6 +15,7 @@ [app.main.data.profile :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] [cuerdas.core :as str] @@ -43,8 +44,9 @@ [:div {:class (stl/css :action-buttons)} (when (some? on-prev) - [:button {:class (stl/css :prev-button) - :on-click on-prev} + [:> button* {:variant "secondary" + :type "button" + :on-click on-prev} (tr "labels.previous")]) [:> fm/submit-button* diff --git a/frontend/src/app/main/ui/onboarding/questions.scss b/frontend/src/app/main/ui/onboarding/questions.scss index 9a1adaccc6..77a8742e87 100644 --- a/frontend/src/app/main/ui/onboarding/questions.scss +++ b/frontend/src/app/main/ui/onboarding/questions.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @@ -22,19 +24,15 @@ @extend %modal-accept-btn; } -.prev-button { - @extend %modal-cancel-btn; -} - .modal-container { - max-width: $sz-964; - max-height: fit-content; + max-inline-size: $sz-964; + max-block-size: fit-content; inline-size: fit-content; padding-inline: $sz-96; padding-block: $sz-40 $sz-64; border-radius: $br-8; - border: $b-2 solid var(--modal-border-color); - background-color: var(--modal-background-color); + border: $b-2 solid var(--color-background-quaternary); + background-color: var(--color-background-primary); } .form-wrapper { @@ -44,15 +42,15 @@ } .paginator { - @include t.use-typography("title-small"); + @include use-typography("title-small"); - text-align: right; - color: var(--modal-text-foreground-color); + text-align: end; + color: var(--color-foreground-secondary); } .radio label, .select span { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } .header-image { @@ -62,25 +60,25 @@ } .modal-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); min-block-size: $sz-32; margin-block: auto; } .modal-subtitle { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); margin: 0; padding: 0; } .modal-text { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); margin: 0; } From 9d5dad3de7389f5f8a348635ca97e45d9e66233e Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Tue, 27 Oct 2026 14:59:22 +0100 Subject: [PATCH 173/325] :arrow_up: Upgrade to Serena v2.1.0 (#12482) * :arrow_up: Upgrade to Serena v2.1.0 Switch to REPL interface (configured in project) Additional changes: * Remove additional Serena project in subfolder mcp/ * :bug: Fix outdated memories --------- Co-authored-by: alonso.torres --- .serena/memories/devenv/core.md | 5 +- .../frontend/composable-component-tests.md | 2 +- .serena/project.yml | 73 +++++--- AGENTS.md | 11 +- docker/devenv/Dockerfile | 4 +- docker/devenv/docker-compose.main.yml | 2 +- mcp/.serena/.gitignore | 1 - mcp/.serena/memories/project_overview.md | 87 --------- mcp/.serena/project.yml | 176 ------------------ 9 files changed, 63 insertions(+), 298 deletions(-) delete mode 100644 mcp/.serena/.gitignore delete mode 100644 mcp/.serena/memories/project_overview.md delete mode 100644 mcp/.serena/project.yml diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index d048aff4d1..415a69342e 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -107,10 +107,7 @@ Two Dockerfile trees, both multi-arch (`linux/amd64,linux/arm64`): per-platform one. - Every hand-pinned checksum is per architecture; one wrong value breaks half of the multi-arch build. -- `SERENA_UPDATE_VERSION` in `docker-compose.main.yml` must stay empty. Any - value makes `start-tmux.sh` reinstall that Serena release on every start, - overriding the `SERENA_VERSION` baked into the image (it silently replaced - 1.7.0 with 1.6.1). Set it only to test one specific release on purpose. +- `SERENA_UPDATE_VERSION` in `docker-compose.main.yml` must never be lower than the Dockerfile's `SERENA_VERSION`. Any value makes `start-tmux.sh` install that Serena release on start, overriding the version baked into the image; a stale value silently downgrades Serena (it once replaced 1.7.0 with 1.6.1). If the image already has that version the install is an offline no-op; otherwise it downloads on every start (`run-devenv` recreates the container) and, offline, fails after ~25s and keeps the image version. Bump both together; empty means use the image version. - Infra images live in `docker-compose.infra.yml`: `rustfs/rustfs` (owns the S3 data volume), `axllent/mailpit` (SQLite in a volume), `valkey/valkey`, `postgres:18.6`, `rroemhild/test-openldap`. Keep the volume names stable diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 6618203d88..f0f586aac2 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -150,5 +150,5 @@ The frontend bundle is built once per SHA and restored by this job (`mem:fronten Details: README, "Running in CI". ## Substrate -`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, +`mem:common/testing`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, `mem:frontend/testing`. diff --git a/.serena/project.yml b/.serena/project.yml index 4b63be5624..0eabf5ed6a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,42 +1,54 @@ # the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "penpot" -# list of languages for which language servers are started (LSP backend only); choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_jedi python_pyrefly python_ty r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# yaml zig +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible astro +# bash bsl clojure cpp cpp_ccls +# crystal csharp csharp_omnisharp cue dart +# deno elixir elm erlang fortran +# fsharp gdscript gleam go groovy +# haskell haxe hlsl html java +# json julia julia_fatou kotlin latex +# lean4 lua lua_emmylua luau markdown +# matlab msl nextflow nix ocaml +# pascal perl php php_devsense php_phpactor +# php_phpantom powershell python python_basedpyright python_jedi +# python_pyrefly python_ty qml r rego +# ruby ruby_solargraph rust scala scss +# solidity svelte swift systemverilog terraform +# toml typescript typescript_vts vue wolfram +# yaml zig # (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of Language enum here: +# For the current list, see values of the LanguageServerId enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript # - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Astro projects, use astro (subsumes typescript/javascript for .astro projects; requires npm) # - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal +# - External Python adapters may add further registered IDs; install the adapter package first +# and then use its ID here, for example: example # Special requirements: -# Some languages require additional setup/installations. +# Some language servers require additional setup/installations. # See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: + - clojure + - typescript + - rust + +# same list for Serena 1.x, which only reads this key; keep both lists equal languages: -- clojure -- typescript -- rust + - clojure + - typescript + - rust # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings @@ -177,3 +189,18 @@ activation_command: # maximum time in seconds to wait for activation_command to complete before killing it (default 180s). # must be a positive number. activation_command_timeout: 180.0 + +# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL +# that would otherwise be disabled (particularly optional methods, which are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_apis: [] + +# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to exclude from the REPL. +# This extends the existing exclusions (e.g. from the global configuration). +excluded_apis: [] + +# The interface through which the agent (LLM) accesses Serena's functionality (overrides the global setting). +# Valid values: tools, REPL (see the global configuration for details); leave empty to use the global setting. +# Note: the interface is fixed at startup. If a project is activated post-init, its setting is not applied. +agent_interface: REPL + diff --git a/AGENTS.md b/AGENTS.md index e900e32518..26db47c2c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Do this **before planning, before coding, before touching any file**: -1. Read `critical-info` (use `serena_read_memory critical-info` or read `.serena/memories/critical-info.md`). +1. Read `critical-info` through Serena (see "Accessing memories" below) or read `.serena/memories/critical-info.md`. It describes the project structure and tells you which modules exist. 2. From `critical-info`, identify which modules your task affects. 3. Read each affected module's **core memory** — the name is `/core` @@ -103,9 +103,12 @@ When working on a task: ## Accessing memories -- **If `serena_read_memory` / `serena_list_memories` tools are available**: use them. - `serena_read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`). -- **If tools are NOT available**: read the filesystem directly. +- **If Serena's `serena_repl` tool is available** (Serena 2.x): call `s.mem.read_memory(name)` + and `s.mem.list_memories()` through it. +- **If Serena's `read_memory` / `list_memories` tools are available** (Serena 1.x; some clients + show them as `serena_read_memory` / `serena_list_memories`): use them. +- Either way, `read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`). +- **If Serena is NOT available**: read the filesystem directly. Memory name `mem:foo/bar` maps to file `.serena/memories/foo/bar.md`. ## Cross-reference convention diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 81fae7a791..ba6b51f2ca 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -236,7 +236,9 @@ ENV CLJKONDO_VERSION=2026.10.20 \ UV_TOOL_DIR=/opt/uv/tools \ UV_TOOL_BIN_DIR=/opt/utils/bin \ UV_PYTHON_INSTALL_DIR=/opt/uv/python \ - SERENA_VERSION=1.7.0 + SERENA_VERSION=2.1.0 +# NOTE: When updating SERENA_VERSION, also update SERENA_UPDATE_VERSION in docker-compose.main.yml. +# The latter results in a dynamic update without rebuilding the image, so it must never be lower than SERENA_VERSION here. RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index 219e348a56..c55330a80a 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -98,7 +98,7 @@ services: # Agentic devenv: set to a PyPI release version/tag to update Serena on startup, # leave empty to skip update and use the version baked into the image. - - SERENA_UPDATE_VERSION= + - SERENA_UPDATE_VERSION=2.1.0 - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/mcp/.serena/.gitignore b/mcp/.serena/.gitignore deleted file mode 100644 index 14d86ad623..0000000000 --- a/mcp/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md deleted file mode 100644 index 64460c0289..0000000000 --- a/mcp/.serena/memories/project_overview.md +++ /dev/null @@ -1,87 +0,0 @@ -# Penpot MCP - -This subproject provides an MCP server for Penpot integration. -The MCP server communicates with a Penpot plugin via WebSockets, allowing -the MCP server to send tasks to the plugin and receive results, -enabling advanced AI-driven features in Penpot. - -## Tech Stack - -- Language: TypeScript -- Runtime: Node.js -- Framework: MCP SDK (@modelcontextprotocol/sdk) -- Build Tool: TypeScript Compiler (tsc) + esbuild -- Package Manager: pnpm - -## General Principles - -IMPORTANT: Use an idiomatic, object-oriented style. -In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions -rather than mere functions (i.e. use the strategy pattern, for example). - -Comments: -When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase -clearly defines *what* it is. Any details then follow in subsequent sentences. - -When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless -the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is -required for sentences). - -## Project Structure (Excerpt) - -``` -/ (project root) -├── packages/common/ # Shared type definitions -│ ├── src/ -│ │ ├── index.ts # exports for shared types -│ │ └── types.ts # PluginTaskResult, request/response interfaces -│ └── package.json # @penpot-mcp/common package -├── packages/server/ # MCP server subproject -│ ├── src/ -│ │ ├── index.ts # entry point -│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.) -│ │ ├── Tool.ts # base class for tools -│ │ ├── PluginTask.ts # base class for plugin tasks -│ │ ├── tasks/ # PluginTask implementations -│ │ └── tools/ # Tool implementations -| ├── data/ # contains resources, such as API info and prompts -│ └── package.json -├── packages/plugin/ # Penpot plugin subproject -│ ├── src/ -│ │ ├── main.ts # handles communication -│ │ └── plugin.ts # plugin implementation -│ └── package.json # Includes @penpot-mcp/common dependency -└── prepare-api-docs # Python project for the generation of API docs -``` - -## Key Development Tasks - -### Adjusting the Prompts - -The system prompt file (aka Penpot High-Level Overview) is located in -`packages/server/data/initial_instructions.md`. - -### Adding a new Tool - -1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. - IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. -2. Register the tool in `PenpotMcpServer`. - -Tools can be associated with a `PluginTask` that is executed in the plugin. -Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution. - -### Adding a new PluginTask - -1. Implement the input data interface for the task in `packages/common/src/types.ts`. -2. Implement the `PluginTask` class in `packages/server/src/tasks/`. -3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). - * In the success case, call `task.sendSuccess`. - * In the failure case, just throw an exception, which will be handled centrally! -4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. - -## Dev Tooling - -From the project root directory, run - -* `pnpm run build` to test the build of all package -* `pnpm run fmt` to apply the auto-formatter diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml deleted file mode 100644 index 3685e20b4d..0000000000 --- a/mcp/.serena/project.yml +++ /dev/null @@ -1,176 +0,0 @@ - -# whether to use project's .gitignore files to ignore files -ignore_all_files_in_gitignore: true - -# list of additional paths to ignore in this project. -# Same syntax as gitignore, so you can use * and **. -# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. -# Example: -# ignored_paths: -# - "examples/**" -# - ".worktrees/**" -# - "**/bin/**" -# - "**/obj/**" -# Note: global ignored_paths from serena_config.yml are also applied additively. -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. -# This extends the existing exclusions (e.g. from the global configuration) -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: | - CRITICAL: Always read the "project_overview" memory. -# the name by which the project can be referenced within Serena/when chatting with the LLM. -project_name: "penpot-mcp" - -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this setting overrides the global configuration. -# Set this to [] to disable base modes for this project. -# Set this to a list of mode names to always include the respective modes for this project. -base_modes: - -# list of mode names that are to be activated by default, overriding the setting in the global configuration. -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this overrides the setting from the global configuration (serena_config.yml). -# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply -# for this project. -# This setting can, in turn, be overridden by CLI parameters (--mode). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -default_modes: - -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). -# This extends the existing inclusions (e.g. from the global configuration). -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -included_optional_tools: [] - -# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. -# This cannot be combined with non-empty excluded_tools or included_optional_tools. -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -fixed_tools: [] - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: utf-8 - -# list of languages for which language servers are started (LSP backend only); choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_jedi python_pyrefly python_ty r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# yaml zig -# (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- typescript - -# time budget (seconds) per tool call for the retrieval of additional symbol information -# such as docstrings or parameter information. -# This overrides the corresponding setting in the global configuration; see the documentation there. -# If null or missing, use the setting from the global configuration. -symbol_info_budget: - -# The language backend to use for this project. -# If not set, the global setting from serena_config.yml is used. -# Valid values: LSP, JetBrains -# Note: the backend is fixed at startup. If a project with a different backend -# is activated post-init, an error will be returned. -language_backend: - -# line ending convention to use when writing source files. -# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) -# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. -line_ending: - -# list of regex patterns which, when matched, mark a memory entry as read‑only. -# Extends the list from the global configuration, merging the two lists. -read_only_memory_patterns: [] - -# list of regex patterns for memories to completely ignore. -# Matching memories will not appear in list_memories or activate_project output -# and cannot be accessed via read_memory or write_memory. -# To access ignored memory files, use the read_file tool on the raw file path. -# Extends the list from the global configuration, merging the two lists. -# Example: ["_archive/.*", "_episodes/.*"] -ignored_memory_patterns: [] - -# advanced configuration option allowing to configure language server-specific options. -# Maps the language key to the options. -# The settings are considered only if the project is trusted (see global configuration to define trusted projects). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings -ls_specific_settings: {} - -# list of mode names to be activated additionally for this project, e.g. ["query-projects"] -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -added_modes: - -# list of additional workspace folder paths for cross-package reference support. -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries, but these folders are not indexed by Serena, -# i.e. the respective symbols will not be found using Serena's symbol search tools. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -ls_additional_workspace_folders: [] - -# list of workspace folder paths (LSP backend only). -# These folders will be used to build up Serena's symbol index. -# Paths must be within the project root and should thus be relative to the project root. -# Furthermore, the paths should not be filtered by ignore settings. -# Default setting: The entire project root folder (".") is considered. -# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. -# ls_workspace_folders: -# - "./subproject1" -# - "./subproject2" -ls_workspace_folders: -- . - -# optional shell command to run before the language backend (LSP or JetBrains) is initialised. -# the command runs in the project root directory and is only executed if the project is trusted -# (see trusted_project_path_patterns in the global configuration). -# serena waits for the command to exit: a non-zero exit code is logged as an error but does not -# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety -# backstop for non-terminating commands; on expiry the process is killed and activation continues. -# example: activation_command: "npx nx run-many -t build" -activation_command: - -# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). -# must be a positive number. -activation_command_timeout: 180.0 From f6f9df9a1a94087ecc9fd30337f908598ada983c Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 27 Oct 2026 16:01:53 +0100 Subject: [PATCH 174/325] :recycle: Use DS icons in the layer row buttons (#12477) * :recycle: Use DS icons in the layer row buttons The expand, visibility and lock buttons of the layer rows drew their icons with the deprecated icon set (arrow, shown, hide, unlock, lock). Use the DS icon instead. The buttons stay raw: they are segments of the layer row, with backgrounds that follow the row state. The icons get a class each, so their colors move from svg rules to those classes, and they keep the old stroke weight. The DS icon draws 12px inside a 16px box: - The expand button start padding goes from 8px to 6px, so the arrow stays in place. - The visibility and lock buttons leave 12px for the icon, so their icons do not shrink (flex-shrink: 0) and stay centered, uncut. The rows look the same as before, pixel for pixel. Add a visual test with a selected, hidden and blocked layer and a hovered row. AI-assisted-by: claude-opus-5-5 * :recycle: Move the layer name styles off the deprecated styles The layer name and its rename input used the deprecated body-small typography, mixins, spacing and token aliases. They now use the DS body-small typography, sizes, borders and px2rem(), and the layer row color aliases resolve to their DS tokens, in layer_item.scss too. The DS body-small has a 1.3 line-height instead of 1.4, so the layer names sit 1px higher. The name color is one local variable for every state; the rename input sets it too, since it also has .element-name. A visual test now covers the rename input. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 62 +++++++++ .../main/ui/workspace/sidebar/layer_item.cljs | 15 ++- .../main/ui/workspace/sidebar/layer_item.scss | 118 +++++++++--------- .../main/ui/workspace/sidebar/layer_name.scss | 52 ++++---- 4 files changed, 154 insertions(+), 93 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index a9a4ef2bf3..5893ae0d80 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -599,6 +599,68 @@ test.describe("Layers panel", () => { }); }); +test.describe("Layers", () => { + test("Shows the buttons of the layer rows", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.clickToggableLayer("Board"); + await workspace.clickToggableLayer("Group"); + const layer = workspace.layers.getByText("A", { exact: true }); + await layer.click(); + await layer.hover(); + await workspace.layers.getByRole("button", { name: "Hide" }).click(); + await workspace.layers.getByRole("button", { name: "Lock" }).click(); + await expect( + workspace.layers.getByRole("button", { name: "Show" }), + ).toBeVisible(); + await expect( + workspace.layers.getByRole("button", { name: "Unlock" }), + ).toBeVisible(); + + // Selected, hidden and blocked layer, with the pointer on its row. + await expect(workspace.layers).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + + // The pointer on a collapsed group, away from the selected layer. + await workspace.layers.getByText("Board", { exact: true }).hover(); + await expect(workspace.layers).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); + + test("Shows the rename input of a layer", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-9066.json"); + await workspace.goToWorkspace({ + fileId: "e179d9df-de35-80bf-8005-283bbd5516b0", + pageId: "e179d9df-de35-80bf-8005-283bbd5516b1", + }); + + await workspace.layers.getByText("Board", { exact: true }).dblclick(); + const input = workspace.layers.getByRole("textbox"); + await expect(input).toBeVisible(); + await page.mouse.move(0, 0); + await expect(workspace.layers).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0, + }); + }); +}); + test.describe("Guides", () => { test("Shows the advanced options of a square guide", async ({ page }) => { // A taller window keeps the whole Design tab visible: a scrolled diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 5733b8c3ce..6438e63914 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -21,9 +21,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] - [app.main.ui.ds.foundations.assets.icon :refer [icon*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.sidebar.layer-name :refer [layer-name*]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -138,7 +137,9 @@ :data-testid "toggle-content" :aria-expanded is-expanded :on-click on-toggle-collapse} - deprecated-icon/arrow]) + [:> icon* {:icon-id i/arrow + :size "s" + :class (stl/css :toggle-content-icon)}]]) [:div {:class (stl/css :icon-shape) :on-double-click on-zoom-to-selected} @@ -189,7 +190,9 @@ (tr "workspace.shape.menu.show") (tr "workspace.shape.menu.hide")) :on-click on-toggle-visibility} - (if ^boolean hidden? deprecated-icon/hide deprecated-icon/shown)] + [:> icon* {:icon-id (if ^boolean hidden? i/hide i/shown) + :size "s" + :class (stl/css :element-action-icon)}]] [:button {:class (stl/css-case :block-element true :selected blocked?) @@ -197,7 +200,9 @@ (tr "workspace.shape.menu.unlock") (tr "workspace.shape.menu.lock")) :on-click on-toggle-blocking} - (if ^boolean blocked? deprecated-icon/lock deprecated-icon/unlock)]])] + [:> icon* {:icon-id (if ^boolean blocked? i/lock i/unlock) + :size "s" + :class (stl/css :element-action-icon)}]]])] children])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index 445b8e5bc9..d7cbad6289 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -27,9 +27,9 @@ &:hover { --layer-background-color: var(--color-background-secondary); --shadow-color: var(--color-background-secondary); - --context-hover-color: var(--layer-row-foreground-color-hover); + --context-hover-color: var(--color-accent-primary); --context-hover-opacity: 1; - --layer-foreground-color: var(--layer-row-foreground-color-hover); + --layer-foreground-color: var(--color-accent-primary); &.hidden { opacity: 1; @@ -200,43 +200,40 @@ align-items: center; block-size: 100%; inline-size: $sz-24; - padding-inline-start: var(--sp-s); - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - fill: none; - block-size: $sz-12; - inline-size: $sz-12; - stroke-width: 1.33px; - stroke: var(--color-foreground-secondary); - - .layer-row.hidden & { - opacity: 0.7; - } - - .layer-row.selected & { - stroke: var(--color-accent-primary); - } - - .layer-row.type-comp & { - stroke: var(--color-accent-secondary); - } - - .layer-row.highlight &, - .layer-row:hover & { - opacity: 1; - stroke: var(--color-accent-primary); - } - } + // The DS icon draws 12px inside a 16px box: 2px less start padding + // keeps the arrow where the old 12px icon was. + padding-inline-start: px2rem(6); .layer-row.selected & { background-color: var(--color-background-quaternary); } +} - &.inverse svg { +.toggle-content-icon { + // Same stroke weight as the old 12px icons. + stroke-width: 1.33px; + color: var(--color-foreground-secondary); + + .layer-row.hidden & { + opacity: 0.7; + } + + .layer-row.selected & { + color: var(--color-accent-primary); + } + + .layer-row.type-comp & { + color: var(--color-accent-secondary); + } + + .layer-row.highlight &, + .layer-row:hover & { + opacity: 1; + color: var(--color-accent-primary); + } + + .toggle-content.inverse & { transform: rotate(90deg); } } @@ -256,26 +253,6 @@ background: var(--layer-row-action-btn-background); padding-inline-end: px2rem(6); - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - fill: none; - block-size: $sz-12; - inline-size: $sz-12; - stroke-width: 1.33px; - stroke: var(--color-foreground-secondary); - - .layer-row.hidden & { - opacity: 0.7; - } - - .type-comp & { - stroke: var(--color-accent-secondary); - } - } - .element-actions.selected & { display: flex; opacity: 0; @@ -294,21 +271,40 @@ display: flex; --layer-row-action-btn-background: var(--color-background-secondary); - - svg { - opacity: 1; - stroke: var(--color-accent-primary); - } } .layer-row.selected & { display: flex; --layer-row-action-btn-background: var(--color-background-quaternary); + } +} - svg { - stroke: var(--color-accent-primary); - } +.element-action-icon { + // The DS icon box is 16px and the button leaves 12px: keep the box + // whole, centered, instead of letting it shrink and cut the icon. + flex-shrink: 0; + + // Same stroke weight as the old 12px icons. + stroke-width: 1.33px; + color: var(--color-foreground-secondary); + + .layer-row.hidden & { + opacity: 0.7; + } + + .type-comp & { + color: var(--color-accent-secondary); + } + + .layer-row.highlight &, + .layer-row:hover & { + opacity: 1; + color: var(--color-accent-primary); + } + + .layer-row.selected & { + color: var(--color-accent-primary); } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss index 23a36b1d1b..6d6fc10fff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss @@ -4,16 +4,22 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; .element-name { - --element-name-color: var(--context-hover-color, var(--layer-row-foreground-color)); - --element-name-comp-color: var(--context-hover-color, var(--layer-row-component-foreground-color)); - --element-name-opacity: var(--context-hover-opacity, deprecated.$op-7); + --element-name-color: var(--context-hover-color, var(--color-foreground-secondary)); + --element-name-opacity: var(--context-hover-opacity, 0.7); - @include deprecated.text-ellipsis; - @include deprecated.body-small-typography; + @include use-typography("body-small"); + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--element-name-color); flex-grow: 1; block-size: 100%; @@ -21,15 +27,11 @@ min-inline-size: var(--sp-xl); &.selected { - color: var(--layer-row-foreground-color-selected); + --element-name-color: var(--color-accent-primary); } &.type-comp { - color: var(--element-name-comp-color); - - &.hidden { - opacity: var(--element-name-opacity); - } + --element-name-color: var(--context-hover-color, var(--color-accent-secondary)); } &.hidden { @@ -37,30 +39,26 @@ } } +// The rename input also carries .element-name, so it takes its typography, +// ellipsis and color from there. .element-name-input { - --element-name-input-border-color: var(--input-border-color-focus); - --element-name-input-color: var(--layer-row-foreground-color); - - @include deprecated.text-ellipsis; - @include deprecated.body-small-typography; - @include deprecated.remove-input-style; + --element-name-color: var(--color-foreground-secondary); + background: none; + outline: none; flex-grow: 1; - height: deprecated.$s-28; + block-size: $sz-28; max-inline-size: calc(var(--parent-size) - (var(--depth) * var(--layer-indentation-size))); margin: 0; - padding-inline-start: deprecated.$s-6; - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid var(--element-name-input-border-color); - color: var(--element-name-input-color); + padding-inline-start: px2rem(6); + border-radius: $br-8; + border: $b-1 solid var(--color-accent-primary); &.selected { - min-inline-size: deprecated.$s-100; + min-inline-size: px2rem(100); } } .element-name-touched { - --element-name-touched-color: var(--layer-row-component-foreground-color); - - color: var(--element-name-touched-color); + color: var(--color-accent-secondary); } From ac52c419036a5226738c2594aa904fe4b106c683 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 09:20:22 +0100 Subject: [PATCH 175/325] :recycle: Use DS icons in the comments panel filter (#12561) * :recycle: Use DS icons in the comments panel filter The comments panel filter (workspace and viewer sidebar) draws its trigger arrow and menu ticks with the DS icons, as the viewer comments filter does. The trigger button says whether its menu is open (aria-expanded, aria-haspopup). The menu items stay: they move with the menus group. The dropdown item placeholder styles any svg inside a span with a 12px box and a stroke color, so the tick icon class sets its 16px box and currentColor stroke. Nothing changes on screen. A visual test covers the trigger and the open menu. AI-assisted-by: claude-opus-5-5 * :recycle: Move comments panel styles closer to the DS The comments panel stylesheet resolves its legacy sizes, spacing, mixins and color aliases to DS values and tokens, with logical properties, and inlines the asset element placeholder of the filter trigger. refactor/ stays only for the dropdown placeholders, which move with the menus group. The legacy body-small typography becomes the DS one, which differs only in its line height (1.3 instead of 1.4): the menu labels sit 1px higher. The trigger looks the same at rest and on hover; the visual test now also covers the hover. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/workspace.spec.js | 26 +++++ .../src/app/main/ui/workspace/comments.cljs | 21 ++-- .../src/app/main/ui/workspace/comments.scss | 109 ++++++++++-------- 3 files changed, 104 insertions(+), 52 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 5893ae0d80..693d10ecff 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1223,3 +1223,29 @@ test.describe("Assets tab controls", () => { await expect(modal).toHaveScreenshot(shot); }); }); + +test.describe("Comments panel filter", () => { + test("Shows the filter trigger and its menu", async ({ page }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.showComments(); + + const trigger = workspace.rightSidebar.getByRole("button", { + name: "Show all comments", + }); + await expect(trigger).toBeVisible(); + await page.mouse.move(640, 600); + await expect(trigger).toHaveScreenshot(shot); + + await trigger.hover(); + await expect(trigger).toHaveScreenshot(shot); + + await trigger.click(); + const menu = page.locator("[class*='comment-mode-dropdown']"); + await expect(menu).toBeVisible(); + await page.mouse.move(640, 600); + await expect(menu).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index a6596bf3b6..7ab6ca7dda 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -17,10 +17,9 @@ [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.context :as ctx] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.ds.product.panel-title :refer [panel-title*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -56,25 +55,29 @@ :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-all-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css-case :dropdown-item true :selected (= :yours cmode)) :data-value "yours" :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-your-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css-case :dropdown-item true :selected (= :mentions cmode)) :data-value "mentions" :on-click update-mode} [:span {:class (stl/css :label)} (tr "labels.show-mentions")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]] + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]] [:li {:class (stl/css :separator)}] [:li {:class (stl/css-case :dropdown-item true :selected (= :pending cshow)) :on-click update-show} [:span {:class (stl/css :label)} (tr "labels.hide-resolved-comments")] - [:span {:class (stl/css :icon)} deprecated-icon/tick]]])) + [:span {:class (stl/css :icon)} + [:> icon* {:icon-id i/tick :size "s" :class (stl/css :tick-icon)}]]]])) (mf/defc comments-sidebar* [{:keys [profiles threads page-id from-viewer]}] @@ -131,6 +134,9 @@ :on-close close-section}] [:button {:class (stl/css :mode-dropdown-wrapper) + :type "button" + :aria-haspopup "menu" + :aria-expanded options? :on-click toggle-mode-selector} [:span {:class (stl/css :mode-label)} @@ -138,7 +144,8 @@ (nil :all) (tr "labels.show-all-comments") :yours (tr "labels.show-your-comments") :mentions (tr "labels.show-mentions"))] - [:div {:class (stl/css :arrow-icon)} deprecated-icon/arrow]] + [:div {:class (stl/css :arrow-icon)} + [:> icon* {:icon-id i/arrow-down :size "s" :class (stl/css :arrow-svg)}]]] [:& dropdown {:show options? :on-close #(reset! state* false)} diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index b94930789d..9e7471e394 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -6,17 +6,22 @@ @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// refactor/ stays for the dropdown placeholders (%dropdown-wrapper, +// %dropdown-element-base), which move with the menus group. @use "refactor/common-refactor.scss" as deprecated; .comments-section { position: relative; - background-color: var(--panel-background-color); + background-color: var(--color-background-primary); display: grid; - grid-template-rows: deprecated.$s-40 deprecated.$s-48 1fr; + grid-template-rows: $sz-40 $sz-48 1fr; } .from-viewer { - padding: 0 deprecated.$s-8; + padding: 0 var(--sp-s); } .comments-title { @@ -24,53 +29,61 @@ } .mode-dropdown-wrapper { - @include deprecated.button-style; - @extend %asset-element; + @include use-typography("body-small"); - background-color: var(--color-background-tertiary); + --mode-dropdown-bg-color: var(--color-background-tertiary); + + border: none; + align-items: center; + color: var(--color-foreground-primary); + background-color: var(--mode-dropdown-bg-color); display: flex; - width: 100%; - max-width: deprecated.$s-256; - height: deprecated.$s-32; - padding: deprecated.$s-8; - border-radius: deprecated.$br-8; - margin: deprecated.$s-16 auto 0 auto; + inline-size: 100%; + max-inline-size: px2rem(256); + block-size: $sz-32; + padding: var(--sp-s); + border-radius: $br-8; + margin: var(--sp-l) auto 0 auto; cursor: pointer; position: relative; + + &:hover { + --mode-dropdown-bg-color: var(--color-background-quaternary); + } } .mode-label { - padding-right: 8px; + padding-inline-end: var(--sp-s); flex-grow: 1; display: flex; justify-content: flex-start; } .arrow-icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon-small; - - transform: rotate(90deg); - stroke: var(--icon-foreground); + .arrow-svg { + flex-shrink: 0; + stroke-width: 1.33px; + color: var(--color-foreground-secondary); } } .comment-mode-dropdown { @extend %dropdown-wrapper; - top: deprecated.$s-92; - left: deprecated.$s-12; - max-width: deprecated.$s-256; - width: 100%; + inset-block-start: px2rem(92); + inset-inline-start: var(--sp-m); + max-inline-size: px2rem(256); + inline-size: 100%; } .viewer-dropdown { - left: deprecated.$s-8; + inset-inline-start: var(--sp-s); } .dropdown-item { @@ -79,35 +92,41 @@ justify-content: space-between; .icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon-small; - - stroke: transparent; + // %dropdown-element-base styles any svg inside a span (12px box and a + // stroke color); the DS icon needs its 16px box and currentColor. + .tick-icon { + flex-shrink: 0; + inline-size: $sz-16; + block-size: $sz-16; + stroke: currentcolor; + stroke-width: 1.33px; + color: transparent; } } .label { - @include deprecated.body-small-typography; + @include use-typography("body-small"); } &:hover { - .icon svg { - stroke: transparent; + .tick-icon { + color: transparent; } } &.selected { .label { - color: var(--menu-foreground-color); + color: var(--color-foreground-primary); } - .icon svg { - stroke: var(--icon-foreground-hover); + .tick-icon { + color: var(--color-foreground-primary); } } } @@ -116,13 +135,13 @@ position: relative; block-size: var(--sp-xs); inline-size: calc(100% + var(--sp-s)); - border-top: $b-1 solid var(--color-background-quaternary); - left: calc(-1 * var(--sp-xs)); - margin-top: var(--sp-s); + border-block-start: $b-1 solid var(--color-background-quaternary); + inset-inline-start: calc(-1 * var(--sp-xs)); + margin-block-start: var(--sp-s); } .comments-section-content { - height: 100%; + block-size: 100%; overflow-y: auto; } From c05b2e2ead3b3a8173c2a43d296f12dab01e9bc3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 09:20:41 +0100 Subject: [PATCH 176/325] :recycle: Use DS buttons and icons in the share link modal (#12562) * :recycle: Use DS buttons and icons in the share link modal - The close button becomes a ghost DS icon button named "Close"; the class that places it goes on the tooltip wrapper. - "Get link", "Destroy link" and the confirmation buttons were raw inputs; they become DS primary, destructive and secondary buttons. - The copy link button stays a raw segment of the link field; its icon becomes the DS clipboard icon, a bit larger than the legacy drawing. - "Manage permissions" stays a link-style toggle; its arrow becomes the DS icon (the rotation still shows the state) and it says whether the permissions are expanded. The page checkboxes and the permission selects stay for their own groups. A new create share link fixture and a visual test cover the modal before and after getting a link, with the permissions expanded, and the destroy confirmation. AI-assisted-by: claude-opus-5-5 * :recycle: Move share link modal styles closer to the DS The share link stylesheet resolves its legacy sizes, spacing, mixins, z-index and color aliases to DS values and tokens, with logical properties. The copy button inlines %button-secondary with local color variables, so it looks the same at rest, on hover, on focus and while pressed; the visual test now also covers its hover. The legacy typographies become the DS ones, each differing in one detail: the title and "Manage permissions" use headline-small (12px instead of 11px) and the texts body-small (line height 1.3 instead of 1.4), so the modal changes height by about 1px. refactor/ stays only for the modal container, the link input and the page checkboxes. AI-assisted-by: claude-opus-5-5 * :recycle: Use a DS icon button for the share link copy button The copy button next to the link was a raw button with the secondary button styles inlined in share_link.scss. It is now a secondary icon-button* named "Copy link", so the .copy-button block goes away (review feedback). Visible changes: the DS secondary look (grey icon at rest, no lighter background on hover) and a 32px wide button instead of 28px, so the link text ends 4px earlier. AI-assisted-by: claude-opus-5-5 --- .../data/viewer/create-share-link.json | 8 + .../ui/visual-specs/visual-viewer.spec.js | 47 +++++ .../src/app/main/ui/viewer/share_link.cljs | 59 +++--- .../src/app/main/ui/viewer/share_link.scss | 178 +++++++++--------- 4 files changed, 173 insertions(+), 119 deletions(-) create mode 100644 frontend/playwright/data/viewer/create-share-link.json diff --git a/frontend/playwright/data/viewer/create-share-link.json b/frontend/playwright/data/viewer/create-share-link.json new file mode 100644 index 0000000000..2d92a75545 --- /dev/null +++ b/frontend/playwright/data/viewer/create-share-link.json @@ -0,0 +1,8 @@ +{ + "~:id": "~u0c7a1f2e-1b2c-8180-8007-0000000000a1", + "~:file-id": "~udd5cc0bb-91ff-81b9-8004-77df9cd3edb1", + "~:who-comment": "team", + "~:who-inspect": "team", + "~:pages": { "~#set": ["~udd5cc0bb-91ff-81b9-8004-77df9cd3edb2"] }, + "~:owner-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b" +} diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 5dac8b7377..450bba4a2e 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -442,3 +442,50 @@ test.describe("Copy buttons", () => { await expect(copy).toHaveScreenshot(shot); }); }); + +test.describe("Share link modal controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Get, copy and destroy link", async ({ page }) => { + const viewerPage = new ViewerPage(page); + await viewerPage.setupLoggedInUser(); + await viewerPage.setupFileWithSingleBoard(); + await viewerPage.mockRPC( + "create-share-link", + "viewer/create-share-link.json", + ); + await viewerPage.goToViewer({ + fileId: singleBoardFileId, + pageId: singleBoardPageId, + }); + + await page.getByRole("button", { name: "Share" }).click(); + const getLink = page.getByRole("button", { name: "Get link" }); + await expect(getLink).toBeVisible(); + const modal = page.locator("[class*='share-link-dialog']"); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await getLink.click(); + const destroy = page.getByRole("button", { name: "Destroy link" }); + await expect(destroy).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + // A raw button with a title before, a DS icon button named "Copy link" after. + await modal + .getByTitle("Copy link") + .or(modal.getByRole("button", { name: "Copy link" })) + .hover(); + await expect(modal).toHaveScreenshot(shot); + + await page.getByText("Manage permissions").click(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + + await destroy.click(); + await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index f0a4c3de22..32e9117908 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -19,6 +19,9 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.select :refer [select]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.util.clipboard :as clipboard] [app.util.dom :as dom] @@ -169,10 +172,11 @@ [:div {:class (stl/css :share-link-header)} [:h2 {:class (stl/css :share-link-title)} (tr "common.share-link.title")] - [:button {:class (stl/css :modal-close-button) - :on-click on-close - :title (tr "labels.close")} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-button) + :aria-label (tr "labels.close") + :icon i/close + :on-click on-close}]] [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :share-link-section)} (when (and (not confirm?) (some? current-link)) @@ -183,10 +187,10 @@ :placeholder (tr "common.share-link.placeholder") :read-only true}] - [:button {:class (stl/css :copy-button) - :title (tr "viewer.header.share.copy-link") - :on-click copy-link} - deprecated-icon/clipboard]]) + [:> icon-button* {:variant "secondary" + :aria-label (tr "viewer.header.share.copy-link") + :icon i/clipboard + :on-click copy-link}]]) [:div {:class (stl/css :hint-wrapper)} (when (not ^boolean confirm?) @@ -197,37 +201,38 @@ [:div {:class (stl/css :description)} (tr "common.share-link.confirm-deletion-link-description")] [:div {:class (stl/css :actions)} - [:input {:type "button" - :class (stl/css :button-cancel) - :on-click #(reset! confirm* false) - :value (tr "labels.cancel")}] - [:input {:type "button" - :class (stl/css :button-danger) - :on-click delete-link - :value (tr "common.share-link.destroy-link")}]]] + [:> button* {:variant "secondary" + :type "button" + :on-click #(reset! confirm* false)} + (tr "labels.cancel")] + [:> button* {:variant "destructive" + :type "button" + :on-click delete-link} + (tr "common.share-link.destroy-link")]]] (some? current-link) - [:input - {:type "button" - :class (stl/css :button-danger) - :on-click try-delete-link - :value (tr "common.share-link.destroy-link")}] + [:> button* {:variant "destructive" + :type "button" + :on-click try-delete-link} + (tr "common.share-link.destroy-link")] :else - [:input - {:type "button" - :class (stl/css :button-active) - :on-click create-link - :value (tr "common.share-link.get-link")}])]] + [:> button* {:variant "primary" + :type "button" + :on-click create-link} + (tr "common.share-link.get-link")])]] (when (not ^boolean confirm?) [:div {:class (stl/css :permissions-section)} [:button {:class (stl/css :manage-permissions) + :type "button" + :aria-expanded perms-visible? :on-click toggle-perms-visibility} [:span {:class (stl/css-case :icon true :rotated perms-visible?)} - deprecated-icon/arrow] + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]] (tr "common.share-link.manage-ops")] (when ^boolean perms-visible? diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index 43e2ba8910..336ad5ede3 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -4,149 +4,140 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +// refactor/ stays for the modal placeholder (%modal-container-base), the +// link input (%input-element) and the page checkboxes (%input-checkbox, +// %button-icon-small for their tick), which move with their own groups. @use "refactor/common-refactor.scss" as deprecated; .share-modal { display: block; position: absolute; - top: deprecated.$s-52; - right: deprecated.$s-12; - left: calc(100vw - deprecated.$s-512); - z-index: deprecated.$z-index-modal; + inset-block-start: px2rem(52); + inset-inline: calc(100vw - #{$sz-512}) var(--sp-m); + z-index: var(--z-index-set); } .share-link-dialog { @extend %modal-container-base; - min-height: unset; + min-block-size: unset; } .share-link-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .share-link-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-button { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.body-small-typography; - @include deprecated.flex-column; + @include use-typography("body-small"); - gap: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); } .share-link-section { - @include deprecated.flex-column; - - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); } .hint-wrapper { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .hint { flex-grow: 1; - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } .custon-input-wrapper { - @include deprecated.flex-row; - - border-radius: deprecated.$br-8; - height: deprecated.$s-32; - width: 100%; - background-color: var(--input-background-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + border-radius: $br-8; + block-size: $sz-32; + inline-size: 100%; + background-color: var(--color-background-tertiary); } .input-text { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--input-foreground-color-active); - padding-left: deprecated.$s-8; + color: var(--color-foreground-primary); + padding-inline-start: var(--sp-s); margin: 0; flex-grow: 1; &:focus { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); - } -} - -.copy-button { - @extend %button-secondary; - @include deprecated.flex-row; - - gap: deprecated.$s-8; - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground-hover); + border: $b-1 solid var(--color-accent-primary); } } .description { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--modal-text-foreground-color); - margin-bottom: deprecated.$s-24; + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .actions { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); justify-content: flex-end; } -.button-active { - @extend %modal-accept-btn; -} - -.button-cancel { - @extend %modal-cancel-btn; -} - -.button-danger { - @extend %modal-danger-btn; -} - .permissions-section { - @include deprecated.flex-column; - - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); } .manage-permissions { - @include deprecated.button-style; - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--menu-foreground-color-rest); - height: deprecated.$s-32; + border: none; + background: none; + cursor: pointer; + color: var(--color-foreground-secondary); + block-size: $sz-32; display: flex; align-items: center; padding: 0; } .icon { - @include deprecated.flex-center; + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: px2rem(6); - margin-right: deprecated.$s-6; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); + .arrow-icon { + flex-shrink: 0; + stroke-width: 1px; + color: var(--color-foreground-secondary); } &.rotated { @@ -158,74 +149,77 @@ .access-mode, .inspect-mode { display: flex; - width: 100%; + inline-size: 100%; } .view-mode { - max-height: deprecated.$s-216; + max-block-size: px2rem(216); overflow: hidden auto; scrollbar-gutter: stable; } .subtitle { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); display: flex; align-items: center; justify-content: flex-start; - width: deprecated.$s-136; - height: deprecated.$s-32; + inline-size: px2rem(136); + block-size: $sz-32; } .items { flex-grow: 1; - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .select-all-row { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); justify-content: space-between; - height: deprecated.$s-32; - border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); + block-size: $sz-32; + border-block-end: $b-1 solid var(--color-background-quaternary); } .select-all-label { - color: var(--input-foreground-color-active); + color: var(--color-foreground-primary); } .pages-selection { margin: 0; + // The page rows have no class of their own. li { - border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); + border-block-end: $b-1 solid var(--color-background-quaternary); } li:last-child { - border-bottom: none; + border-block-end: none; } } .count-pages, .current-tag { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--input-foreground-color); + color: var(--color-foreground-secondary); } .checkbox-wrapper { @extend %input-checkbox; - height: deprecated.$s-32; + block-size: $sz-32; padding: 0; span.checked { - background-color: var(--input-checkbox-background-color-active); - border: deprecated.$s-1 solid var(--input-checkbox-background-color-active); + background-color: var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); + // The legacy tick icon has no class; it moves with the checkboxes. svg { @extend %button-icon-small; - stroke: var(--input-checkbox-foreground-color-active); + stroke: var(--color-background-primary); } } } From 08228324db14df2c0b8a57408af37a6e3af550e5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 09:21:00 +0100 Subject: [PATCH 177/325] :recycle: Use DS icons in the dashboard templates section (#12535) The arrow and add icons of the dashboard libraries and templates section use the DS icons and get their color from the color property. The collapse button says whether the section is expanded, and the left scroll button, which had no name, is now named "Previous". The scroll buttons stay raw: they are round 36px buttons with no DS equivalent. The template cards are anchors without href; they go to the anchors backlog. A new fixture lists eight builtin templates, and a visual test covers the cards, a card hover, the scroll buttons and the collapsed title. No visual change. AI-assisted-by: claude-opus-5-5 --- .../data/dashboard/get-builtin-templates.json | 34 +++++++++++++++ .../ui/visual-specs/visual-dashboard.spec.js | 43 +++++++++++++++++++ .../src/app/main/ui/dashboard/templates.cljs | 27 ++++++------ .../src/app/main/ui/dashboard/templates.scss | 12 ++---- 4 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 frontend/playwright/data/dashboard/get-builtin-templates.json diff --git a/frontend/playwright/data/dashboard/get-builtin-templates.json b/frontend/playwright/data/dashboard/get-builtin-templates.json new file mode 100644 index 0000000000..ab08f3b31d --- /dev/null +++ b/frontend/playwright/data/dashboard/get-builtin-templates.json @@ -0,0 +1,34 @@ +[ + { + "~:id": "tokens-starter-kit", + "~:name": "Design tokens starter kit" + }, + { + "~:id": "penpot-design-system", + "~:name": "Penpot Design System | Pencil" + }, + { + "~:id": "wireframing-kit", + "~:name": "Wireframe library" + }, + { + "~:id": "prototype-examples", + "~:name": "Prototype template" + }, + { + "~:id": "plants-app", + "~:name": "UI mockup example" + }, + { + "~:id": "tutorial-for-beginners", + "~:name": "Tutorial for beginners" + }, + { + "~:id": "lucide-icons", + "~:name": "Lucide Icons" + }, + { + "~:id": "font-awesome", + "~:name": "Font Awesome" + } +] diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 06e3cd2639..d4563b69d2 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -629,3 +629,46 @@ test.describe("Onboarding questions controls", () => { await expect(actions).toHaveScreenshot(shot); }); }); + +test.describe("Templates section controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Template cards, scroll buttons and collapse", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.mockRPC( + "get-builtin-templates", + "dashboard/get-builtin-templates.json", + ); + await dashboardPage.goToDashboard(); + + const section = page.locator("[class*='dashboard-templates-section']"); + const card = section.locator("[class*='template-card']").first(); + await expect(card).toBeVisible(); + await expect(section.locator("img").first()).toHaveJSProperty( + "complete", + true, + ); + await page.mouse.move(640, 100); + await expect(section).toHaveScreenshot(shot); + + // Hovering a card shows its add icon in the accent color. + await card.hover(); + await expect(card).toHaveScreenshot(shot); + + const moveButtons = section.locator("[class*='move-button']"); + await moveButtons.last().hover(); + await expect(moveButtons.last()).toHaveScreenshot(shot); + + await moveButtons.last().click(); + await expect(moveButtons).toHaveCount(2); + await page.mouse.move(640, 100); + await expect(moveButtons.first()).toHaveScreenshot(shot); + + await section.locator("[class*='title-btn']").click(); + await expect(section).toHaveClass(/collapsed/); + await page.waitForTimeout(500); + await expect(section.locator("[class*='title-btn']")).toHaveScreenshot( + shot, + ); + }); +}); diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index cecb4db1aa..55e0176651 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -16,7 +16,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] [app.util.dom.normalize-wheel :as nw] [app.util.i18n :refer [tr]] @@ -25,12 +25,6 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private download-icon - (deprecated-icon/icon-xref :add (stl/css :download-icon))) - (def builtin-templates (l/derived :builtin-templates st/state)) @@ -77,6 +71,7 @@ [:div {:class (stl/css :title)} [:button {:tab-index "0" :class (stl/css :title-btn) + :aria-expanded (not is-collapsed) :on-click on-click :on-key-down on-key-down} [:span {:class (stl/css :title-text)} @@ -85,11 +80,13 @@ [:span {:class (stl/css :title-icon-container)} [:span {:class (stl/css :title-icon-text)} (tr "labels.show")] [:span {:class (stl/css :title-icon :title-icon-collapsed)} - arrow-icon]] + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]] [:span {:class (stl/css :title-icon-container)} [:span {:class (stl/css :title-icon-text)} (tr "labels.hide")] [:span {:class (stl/css :title-icon)} - arrow-icon]])]])) + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]])]])) (mf/defc card-item* [{:keys [item index is-visible collapsed on-import]}] @@ -131,7 +128,8 @@ (if @hover? (tr "dashboard.template.add-to-project") (:name item))] - download-icon]]])) + [:> icon* {:icon-id i/add + :class (stl/css :download-icon)}]]]])) (mf/defc card-item-link* [{:keys [total is-visible collapsed section]}] @@ -286,8 +284,10 @@ [:button {:class (stl/css :move-button :move-left) :tab-index (if ^boolean collapsed "-1" "0") :on-click on-move-left - :on-key-down on-move-left} - arrow-icon]) + :on-key-down on-move-left + :aria-label (tr "labels.previous")} + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]]) (when (:right @can-move) [:button {:class (stl/css :move-button :move-right) @@ -295,4 +295,5 @@ :on-click on-move-right :on-key-down on-move-right :aria-label (tr "labels.next")} - arrow-icon])])) + [:> icon* {:icon-id i/arrow + :class (stl/css :arrow-icon)}]])])) diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index eabb3c8976..1be61d4465 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -105,10 +105,8 @@ align-items: center; height: $sz-16; width: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--color-foreground-secondary); + color: var(--color-foreground-secondary); } .move-button { @@ -131,7 +129,7 @@ background-color: var(--color-accent-primary); .arrow-icon { - stroke: var(--color-background-tertiary); + color: var(--color-background-tertiary); } } } @@ -196,7 +194,7 @@ border-color: var(--color-accent-primary); .download-icon { - stroke: var(--color-accent-primary); + color: var(--color-accent-primary); } .card-text { @@ -243,10 +241,8 @@ align-items: center; height: $sz-16; width: $sz-16; - color: transparent; - fill: none; stroke-width: $b-1; - stroke: var(--icon-foreground); + color: var(--color-foreground-secondary); } .template-link { From b1018c77b7fd742f6e693aba05cca349fe12b87b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 10:39:20 +0100 Subject: [PATCH 178/325] :recycle: Use DS icons in the legacy context notification (#12542) * :recycle: Use DS icons in the legacy context notification The legacy context notification (still used by the global context notifications, the fonts page, the team, change email, delete account and export modals) draws its level icon with the DS icons instead of the deprecated icon set, colored through the color property. The icons are the same drawings, so nothing changes on screen. The component stays: the DS context notification has no links, which the global context notifications use. A visual test covers the info and warning levels and a warning with html content. AI-assisted-by: claude-opus-5-5 * :recycle: Move the legacy context notification styles to the DS The legacy context notification no longer imports refactor/: its sizes, spacing, borders and alert color aliases resolve to DS tokens, with logical properties. The legacy body-small typography becomes the DS one, which differs only in its line height (1.3 instead of 1.4): one line notifications do not change, and multi-line ones are about 1.5px shorter per extra line. The unused .default level is commented out with a TODO. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the legacy context notification The SCSS refactor of the legacy context notification left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .default. No visual change. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 36 ++++++++ .../notifications/context_notification.cljs | 28 ++----- .../notifications/context_notification.scss | 82 +++++++++---------- 3 files changed, 82 insertions(+), 64 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index d4563b69d2..007092884f 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -672,3 +672,39 @@ test.describe("Templates section controls", () => { ); }); }); + +test.describe("Context notification", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Info and warning levels", async ({ page }) => { + await page.goto("/?screen=settings-profile"); + + // Info: the change email modal. + await page.getByText("Change email", { exact: true }).click(); + const info = page.locator("[class*='modal-container'] aside"); + await expect(info).toBeVisible(); + await expect(info).toHaveScreenshot(shot); + await page.keyboard.press("Escape"); + await expect(info).toBeHidden(); + + // Warning: the delete account modal. + await page.getByText("Want to remove your account?").click(); + const warning = page.locator("[class*='modal-container'] aside"); + await expect(warning).toBeVisible(); + await expect(warning).toHaveScreenshot(shot); + }); + + test("Warning with html content", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await page + .locator("#font-upload") + .setInputFiles("playwright/data/render-wasm/assets/ebgaramond.ttf"); + const warning = page + .locator("[class*='dashboard-fonts-hero'] aside") + .last(); + await expect(warning).toBeVisible(); + await expect(warning).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index 923a4958a3..9455f284c2 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -10,29 +10,16 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.main.ui.components.link-button :as lb] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [rumext.v2 :as mf])) -(def ^:private neutral-icon - (deprecated-icon/icon-xref :msg-neutral (stl/css :icon))) - -(def ^:private error-icon - (deprecated-icon/icon-xref :delete-text (stl/css :icon))) - -(def ^:private success-icon - (deprecated-icon/icon-xref :status-tick (stl/css :icon))) - -(def ^:private info-icon - (deprecated-icon/icon-xref :help (stl/css :icon))) - -(defn get-icon-by-level +(defn- level->icon-id [level] (case level - :warning neutral-icon - :error error-icon - :success success-icon - :info info-icon - neutral-icon)) + :error i/delete-text + :success i/status-tick + :info i/help + i/msg-neutral)) (mf/defc context-notification "They are persistent, informative and non-actionable. @@ -49,7 +36,8 @@ :success (= level :success) :info (= level :info)))} - (get-icon-by-level level) + [:> icon* {:icon-id (level->icon-id level) + :class (stl/css :icon)}] ;; The content can arrive in markdown format, in these cases ;; we will use the prop is-html to true to indicate it and diff --git a/frontend/src/app/main/ui/notifications/context_notification.scss b/frontend/src/app/main/ui/notifications/context_notification.scss index e4cb80b794..cd8de495c1 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.scss +++ b/frontend/src/app/main/ui/notifications/context_notification.scss @@ -4,71 +4,65 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; .context-notification { - --context-notification-bg-color: var(--alert-background-color-default); - --context-notification-fg-color: var(--alert-text-foreground-color-default); - --context-notification-icon-color: var(--alert-icon-foreground-color-default); - --context-notification-border-color: var(--alert-border-color-default); + --context-notification-bg-color: var(--color-background-primary); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-foreground-primary); + --context-notification-border-color: var(--color-background-quaternary); box-sizing: border-box; display: grid; - grid-template-columns: deprecated.$s-16 1fr; - gap: deprecated.$s-8; - min-height: deprecated.$s-32; - height: fit-content; - width: 100%; - padding: deprecated.$s-8; - border: deprecated.$s-1 solid var(--context-notification-border-color); - border-radius: deprecated.$br-8; + grid-template-columns: $sz-16 1fr; + gap: var(--sp-s); + min-block-size: $sz-32; + block-size: fit-content; + inline-size: 100%; + padding: var(--sp-s); + border: $b-1 solid var(--context-notification-border-color); + border-radius: $br-8; background-color: var(--context-notification-bg-color); } .warning { - --context-notification-bg-color: var(--alert-background-color-warning); - --context-notification-fg-color: var(--alert-text-foreground-color-warning); - --context-notification-icon-color: var(--alert-icon-foreground-color-warning); - --context-notification-border-color: var(--alert-border-color-warning); + --context-notification-bg-color: var(--color-background-warning); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-warning); + --context-notification-border-color: var(--color-accent-warning); } .success { - --context-notification-bg-color: var(--alert-background-color-success); - --context-notification-fg-color: var(--alert-text-foreground-color-success); - --context-notification-icon-color: var(--alert-icon-foreground-color-success); - --context-notification-border-color: var(--alert-border-color-success); + --context-notification-bg-color: var(--color-background-success); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-success); + --context-notification-border-color: var(--color-accent-success); } .info { - --context-notification-bg-color: var(--alert-background-color-info); - --context-notification-fg-color: var(--alert-text-foreground-color-info); - --context-notification-icon-color: var(--alert-icon-foreground-color-info); - --context-notification-border-color: var(--alert-border-color-info); -} - -.default { - --context-notification-bg-color: var(--alert-background-color-default); - --context-notification-fg-color: var(--alert-text-foreground-color-default); - --context-notification-icon-color: var(--alert-icon-foreground-color-default); - --context-notification-border-color: var(--alert-border-color-default); + --context-notification-bg-color: var(--color-background-info); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-info); + --context-notification-border-color: var(--color-accent-info); } .error { - --context-notification-bg-color: var(--alert-background-color-error); - --context-notification-fg-color: var(--alert-text-foreground-color-error); - --context-notification-icon-color: var(--alert-icon-foreground-color-error); - --context-notification-border-color: var(--alert-border-color-error); + --context-notification-bg-color: var(--color-background-error); + --context-notification-fg-color: var(--color-foreground-primary); + --context-notification-icon-color: var(--color-accent-error); + --context-notification-border-color: var(--color-accent-error); } .icon { - @extend %button-icon; - + flex-shrink: 0; align-self: self-start; - stroke: var(--context-notification-icon-color); + color: var(--context-notification-icon-color); } .context-text { - @include deprecated.body-small-typography; + @include use-typography("body-small"); align-self: center; color: var(--context-notification-fg-color); @@ -81,12 +75,12 @@ .link, .contain-html .context-text a { - @include deprecated.body-small-typography; + @include use-typography("body-small"); align-self: center; display: inline; - text-align: left; - height: deprecated.$s-16; + text-align: start; + block-size: $sz-16; margin: 0; - color: var(--modal-link-foreground-color); + color: var(--color-accent-primary); } From 4d416bd3f21bf6d7ce2330facbfdba54c5149f44 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 10:39:35 +0100 Subject: [PATCH 179/325] :recycle: Use DS icons and buttons in the settings sidebar and email modal (#12541) * :recycle: Use DS icons and buttons in the settings sidebar and email modal - The settings sidebar back arrow and "Contact us" icon use the DS icons. The arrow keeps its place; the small DS icon draws 12px inside a 16px box, so the feedback icon sits 2px to the right and its margin shrinks to keep the label in place. - The change email modal close button becomes a ghost DS icon button named "Close"; the class that places it goes on the tooltip wrapper. The form submit buttons of the settings pages stay: they are the shared forms submit button. A visual test covers the settings sidebar and the change email modal. AI-assisted-by: claude-opus-5-5 * :recycle: Move settings sidebar and email modal styles to the DS The settings sidebar no longer imports refactor/: its legacy mixins, sizes and color aliases resolve to DS values and tokens, with logical properties. Its z-index (1) has no DS token and keeps a TODO. The change email modal does the same and keeps refactor/ only for the modal placeholders (%modal-*), which move with the modals refactor. Its legacy uppercase title becomes the DS headline-small typography, which differs only in its size (12px instead of 11px): the modal grows 2px. Two unused selectors are commented out with a TODO. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the change email modal The SCSS refactor of the change email modal left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .select-title, .cancel-button. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Tidy the settings sidebar and change email styles The sidebar uses --z-index-panels instead of auto, the change email submit button gets its own class instead of a bare button selector, and the sidebar icons keep the DS stroke width (only the feedback icon line gets thinner). AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 26 ++++++++ .../app/main/ui/settings/change_email.cljs | 13 ++-- .../app/main/ui/settings/change_email.scss | 44 ++++++------- .../src/app/main/ui/settings/sidebar.cljs | 14 ++--- .../src/app/main/ui/settings/sidebar.scss | 61 ++++++++++--------- 5 files changed, 95 insertions(+), 63 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 007092884f..6e5a868789 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -602,6 +602,32 @@ test.describe("Team pages controls", () => { }); }); +test.describe("Settings controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Sidebar and change email modal", async ({ page }) => { + await DashboardPage.mockConfigFlags(page, ["enable-user-feedback"]); + await page.goto("/?screen=settings-profile"); + const sidebar = page.locator( + "[class*='settings_sidebar__dashboard-sidebar']", + ); + await expect(page.getByText("Contact us")).toBeVisible(); + await page.mouse.move(640, 700); + await expect(sidebar).toHaveScreenshot(shot); + + await page.getByText("Dashboard", { exact: true }).hover(); + await expect(sidebar).toHaveScreenshot(shot); + + await page.getByText("Change email", { exact: true }).click(); + const modal = page.locator("[class*='modal-container']"); + await expect(modal).toBeVisible(); + await page.mouse.move(10, 700); + await expect(modal).toHaveScreenshot(shot); + await modal.locator("[class*='modal-header'] button").hover(); + await expect(modal).toHaveScreenshot(shot); + }); +}); + test.describe("Onboarding questions controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index 98e15e2c58..613ae3a9d6 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -14,7 +14,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -88,8 +89,11 @@ [:h2 {:class (stl/css :modal-title) :data-testid "change-email-title"} (tr "modals.change-email.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :modal-close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click modal/hide!}]] [:div {:class (stl/css :modal-content)} [:& context-notification @@ -114,7 +118,8 @@ [:div {:class (stl/css :action-buttons) :data-testid "change-email-submit"} [:> fm/submit-button* - {:label (tr "modals.change-email.submit")}]]]]]])) + {:label (tr "modals.change-email.submit") + :class (stl/css :submit-button)}]]]]]])) diff --git a/frontend/src/app/main/ui/settings/change_email.scss b/frontend/src/app/main/ui/settings/change_email.scss index 4795cf6ac6..afff303004 100644 --- a/frontend/src/app/main/ui/settings/change_email.scss +++ b/frontend/src/app/main/ui/settings/change_email.scss @@ -4,7 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -13,49 +17,45 @@ .modal-container { @extend %modal-container-base; - min-width: deprecated.$s-408; + min-inline-size: px2rem(408); } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } .modal-title { - @include deprecated.uppercase-title-typography; + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } +// Tooltip wrapper of the close button: it places it in the corner. .modal-close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-content { - @include deprecated.flex-column; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - gap: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + margin-block-end: var(--sp-xxl); } .fields-row { - @include deprecated.flex-column; -} - -.select-title { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); + display: flex; + flex-direction: column; + gap: var(--sp-xs); } .action-buttons { @extend %modal-action-btns; - - button { - @extend %modal-accept-btn; - } } -.cancel-button { - @extend %modal-cancel-btn; +.submit-button { + @extend %modal-accept-btn; } diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 652842ff26..3bd4545bf6 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -15,16 +15,11 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.dashboard.sidebar :refer [profile-section*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(def ^:private arrow-icon - (deprecated-icon/icon-xref :arrow (stl/css :arrow-icon))) - -(def ^:private feedback-icon - (deprecated-icon/icon-xref :feedback (stl/css :feedback-icon))) ;; FIXME: move to common (def ^:private go-settings-profile @@ -82,7 +77,8 @@ [:div {:class (stl/css :sidebar-content-section)} [:button {:class (stl/css :back-to-dashboard) :on-click go-dashboard} - arrow-icon + [:> icon* {:icon-id i/arrow-left + :class (stl/css :arrow-icon)}] [:span {:class (stl/css :back-text)} (tr "labels.dashboard")]]] [:hr {:class (stl/css :sidebar-separator)}] @@ -143,7 +139,9 @@ [:li {:class (stl/css-case :current feedback? :settings-item true) :on-click go-settings-feedback} - feedback-icon + [:> icon* {:icon-id i/feedback + :size "s" + :class (stl/css :feedback-icon)}] [:span {:class (stl/css :element-title)} (tr "labels.contact-us")]])]]])) (mf/defc sidebar* diff --git a/frontend/src/app/main/ui/settings/sidebar.scss b/frontend/src/app/main/ui/settings/sidebar.scss index e63882a113..dc905a9911 100644 --- a/frontend/src/app/main/ui/settings/sidebar.scss +++ b/frontend/src/app/main/ui/settings/sidebar.scss @@ -4,8 +4,11 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; @use "ds/mixins.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/z-index.scss" as *; .dashboard-sidebar { @include custom-scrollbar; @@ -14,24 +17,24 @@ grid-row: 1 / span 2; display: grid; grid-template-rows: 1fr auto; - height: 100%; - padding-block-start: deprecated.$s-16; - border-right: deprecated.$s-1 solid var(--panel-border-color); - z-index: deprecated.$z-index-1; - background-color: var(--panel-background-color); + block-size: 100%; + padding-block-start: var(--sp-l); + border-inline-end: $b-1 solid var(--color-background-quaternary); + z-index: var(--z-index-panels); + background-color: var(--color-background-primary); } .sidebar-content { display: grid; grid-template-rows: auto auto 1fr; - height: 100%; + block-size: 100%; padding: 0; overflow-y: auto; } .sidebar-separator { border-color: transparent; - margin: deprecated.$s-12 deprecated.$s-16; + margin: var(--sp-m) var(--sp-l); } .sidebar-nav-settings { @@ -43,46 +46,48 @@ } .settings-item { - --settings-foreground-color: var(--menu-foreground-color-rest); + --settings-foreground-color: var(--color-foreground-secondary); --settings-background-color: transparent; display: flex; align-items: center; - padding: deprecated.$s-8 deprecated.$s-8 deprecated.$s-8 deprecated.$s-24; + padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-xxl); color: var(--settings-foreground-color); background-color: var(--settings-background-color); cursor: pointer; &:hover { - --settings-foreground-color: var(--sidebar-element-foreground-color-hover); - --settings-background-color: var(--sidebar-element-background-color-hover); + --settings-foreground-color: var(--color-accent-primary); + --settings-background-color: var(--color-background-secondary); } &.current { - --settings-foreground-color: var(--sidebar-element-foreground-color-selected); - --settings-background-color: var(--sidebar-element-background-color-selected); + --settings-foreground-color: var(--color-accent-primary); + --settings-background-color: var(--color-background-quaternary); } } +// The DS small icon draws 12px inside a 16px box: 4px less margin keeps +// the label where it was (the drawing sits 2px to the right). .feedback-icon { - @extend %button-icon-small; - - stroke: var(--settings-foreground-color); - margin-right: deprecated.$s-8; + flex-shrink: 0; + color: var(--settings-foreground-color); + margin-inline-end: var(--sp-xs); } .element-title { - @include deprecated.text-ellipsis; - @include deprecated.body-medium-typography; + @include text-ellipsis; + @include use-typography("body-medium"); } .back-to-dashboard { - @include deprecated.button-style; - + border: none; + background: none; + cursor: pointer; display: flex; align-items: center; - padding: deprecated.$s-12 deprecated.$s-16; - font-size: deprecated.$fs-14; + padding: var(--sp-m) var(--sp-l); + font-size: px2rem(14); } .back-text { @@ -90,9 +95,7 @@ } .arrow-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); - transform: rotate(180deg); - margin-right: deprecated.$s-12; + flex-shrink: 0; + color: var(--color-foreground-secondary); + margin-inline-end: var(--sp-m); } From ff9506c551475e03be1ad9ab96f718c006a3a53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 28 Oct 2026 11:02:42 +0100 Subject: [PATCH 180/325] :sparkles: Add member profile support for organization attributes (#12652) --- .../src/app/loggers/audit/consumer_task.clj | 2 + backend/src/app/rpc/management/nitrate.clj | 51 ++++++++++++++++++- .../rpc_management_nitrate_test.clj | 47 +++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/backend/src/app/loggers/audit/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj index 7270cfae94..3180f936f0 100644 --- a/backend/src/app/loggers/audit/consumer_task.clj +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -213,6 +213,7 @@ "accept-team-invitation-from" "add-member-to-organization" "add-team-to-organization" + "assign-organization-attribute" "auditlog-exported" "auditlog-guide-downloaded" "cancel-organization-invitation" @@ -253,6 +254,7 @@ "sso-config-tested" "success-activate-organization-sso" "success-apply-organization-sso-changes" + "unassign-organization-attribute" "update-organization-invitation" "update-organization-permissions" "update-team-invitation" diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index c8c4f83e8c..20cd899ae7 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -17,7 +17,7 @@ [app.common.time :as ct] [app.common.types.organization :as cto] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] - [app.common.types.team :refer [schema:team]] + [app.common.types.team :as ctt :refer [schema:team]] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -562,6 +562,55 @@ RETURNING id, deleted_at;") (db/exec! conn [sql:get-organization-member-team-counts ids-array]))))))) +;; ---- API: get-organization-member-teams + +(def ^:private sql:get-organization-member-teams + "SELECT t.id, t.name, t.photo_id, + CASE WHEN tpr.is_owner THEN 'owner' + WHEN tpr.is_admin THEN 'admin' + WHEN tpr.can_edit THEN 'editor' + ELSE 'viewer' + END AS role + FROM team_profile_rel AS tpr + JOIN team AS t ON t.id = tpr.team_id + WHERE tpr.profile_id = ? + AND t.id = ANY(?) + AND t.deleted_at IS NULL + AND t.is_default IS FALSE;") + +(def ^:private schema:get-organization-member-teams-params + [:map + [:profile-id ::sm/uuid] + [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) + +(def ^:private schema:get-organization-member-teams-result + [:vector [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:photo-url {:optional true} ::sm/uri] + [:role ctt/schema:role]]]) + +(sv/defmethod ::get-organization-member-teams + "Get the non-default teams a profile belongs to within a set of teams, + with the profile's role in each one." + {::doc/added "2.18" + ::sm/params schema:get-organization-member-teams-params + ::sm/result schema:get-organization-member-teams-result + ::nitrate/sso false} + [cfg {:keys [profile-id team-ids]}] + (let [team-ids (if (uuid? team-ids) [team-ids] team-ids)] + (if (empty? team-ids) + [] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-organization-member-teams profile-id ids-array]) + (mapv (fn [{:keys [photo-id role] :as row}] + (cond-> (-> row + (dissoc :photo-id) + (assoc :role (keyword role))) + photo-id (assoc :photo-url (files/resolve-public-uri photo-id)))))))))))) + + ;; API: invite-to-organization (defn- get-invitation-organization diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 500b241279..2d411bf816 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -251,6 +251,53 @@ (t/is (false? (get-in teams-by-id [(:id owned-team) :is-default]))) (t/is (false? (get-in teams-by-id [(:id member-team) :is-default]))))))) +(t/deftest get-organization-member-teams-returns-roles-within-given-teams + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + owned-team (th/create-team* 1 {:profile-id (:id profile)}) + admin-team (th/create-team* 2 {:profile-id (:id other)}) + editor-team (th/create-team* 3 {:profile-id (:id other)}) + viewer-team (th/create-team* 4 {:profile-id (:id other)}) + foreign-team (th/create-team* 5 {:profile-id (:id other)}) + not-member (th/create-team* 6 {:profile-id (:id other)}) + deleted-team (th/create-team* 7 {:profile-id (:id profile)}) + _ (th/db-update! :team + {:deleted-at (ct/now)} + {:id (:id deleted-team)}) + _ (doseq [[team role] [[admin-team :admin] + [editor-team :editor] + [viewer-team :viewer] + [foreign-team :editor]]] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id profile) + :role role})) + team-ids (mapv :id [owned-team admin-team editor-team viewer-team + not-member deleted-team + {:id (:default-team-id profile)}]) + out (th/management-command! {::th/type :get-organization-member-teams + ::rpc/profile-id (:id other) + :profile-id (:id profile) + :team-ids team-ids})] + (t/is (th/success? out)) + (t/is (= {(:id owned-team) :owner + (:id admin-team) :admin + (:id editor-team) :editor + (:id viewer-team) :viewer} + (->> out :result (map (juxt :id :role)) (into {})))) + (t/is (= (:name owned-team) + (->> out :result (d/seek #(= (:id owned-team) (:id %))) :name)))))) + +(t/deftest get-organization-member-teams-empty-team-ids + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/management-command! {::th/type :get-organization-member-teams + ::rpc/profile-id (:id profile) + :profile-id (:id profile) + :team-ids []})] + (t/is (th/success? out)) + (t/is (= [] (:result out)))))) + (t/deftest update-profile-theme-updates-only-theme (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true From 1109e388523031ed77cf29b772df5fcb5bc806ba Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 12:10:47 +0100 Subject: [PATCH 181/325] :recycle: Use DS icons in the dashboard lists and placeholders (#12536) * :recycle: Use DS icons in the dashboard lists and placeholders The trash project options button becomes a ghost DS icon button, as in the projects list, and says whether its menu is open. The search placeholders, the empty project "create new file" button, the comments placeholder and the legacy import library tag use the DS icons. The legacy search icon had no fill rule, so its lens was filled black; the DS one is not. Two fixtures make the trash testable: a deleted project, and deleted files due in 2100 (the existing ones expired in 2024, so the trash dropped them). A visual test covers the trash row, the empty project button, the project rename field and the comments placeholder. AI-assisted-by: claude-opus-5-5 * :bug: Remove the hidden close control from the dashboard rename field The rename field of dashboard projects and files had a close control next to the input that never showed: its legacy icon is drawn with a stroke, but the styles only set a fill. It could not be reached with the keyboard or a screen reader, and a click on it did what leaving the field does: the blur saves the name first, and its handler saved it too. It only left a pointer cursor over an empty spot. Enter, Escape and leaving the field keep working as before. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard trash, comments and rename styles to the DS The trash, comments panel and rename field stylesheets resolve their legacy color aliases, sizes, font sizes, mixins and the menu shadow to DS tokens and values, and use logical properties. The comments panel and the rename field no longer import refactor/; the trash keeps only the shared dashboard header styles (common-dashboard), used by ten dashboard modules. Four selectors that the modules do not use are commented out with a TODO. The comments dropdown z-index (4) has no DS token and keeps a TODO. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the dashboard trash and comments The SCSS refactor of the dashboard trash and comments left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .thread-group, .comments-icon-small, .add-file-btn, .add-icon. No visual change. AI-assisted-by: claude-opus-5-5 * :bug: Use px2rem for the unread dot size in dashboard comments The unread dot used $sz-8, which the DS sizes do not define, so the integration bundle build failed. It now uses px2rem(8), the same 8px. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS typography and z-index in the dashboard comments Review fixes for the dashboard lists styles: - The comments dropdown title uses the headline-small typography. It was 11px regular, so it is now 12px medium (larger and bolder). - The comments dropdown uses --z-index-dropdown instead of a raw 4. - The rename field selector is the class alone; the input type is not needed to win over the input defaults (0px change). The visual test now also captures the whole comments dropdown, so the title change shows. AI-assisted-by: claude-opus-5-5 * :fire: Remove dead rules from the dashboard comments styles The comments dropdown has no separators, so its hr rule matched nothing, and the comments icon set the default stroke width. No visual change. AI-assisted-by: claude-opus-5-5 --- .../data/dashboard/get-projects-deleted.json | 32 +++++++ .../get-team-deleted-files-pending.json | 47 ++++++++++ .../ui/visual-specs/visual-dashboard.spec.js | 72 +++++++++++++++ .../src/app/main/ui/dashboard/comments.cljs | 10 +-- .../src/app/main/ui/dashboard/comments.scss | 90 ++++++++----------- .../src/app/main/ui/dashboard/deleted.cljs | 21 +++-- .../src/app/main/ui/dashboard/deleted.scss | 51 +++++------ .../src/app/main/ui/dashboard/import.cljs | 3 +- .../app/main/ui/dashboard/inline_edition.cljs | 5 +- .../app/main/ui/dashboard/inline_edition.scss | 47 +++------- .../app/main/ui/dashboard/placeholder.cljs | 4 +- .../src/app/main/ui/dashboard/search.cljs | 11 ++- 12 files changed, 246 insertions(+), 147 deletions(-) create mode 100644 frontend/playwright/data/dashboard/get-projects-deleted.json create mode 100644 frontend/playwright/data/dashboard/get-team-deleted-files-pending.json diff --git a/frontend/playwright/data/dashboard/get-projects-deleted.json b/frontend/playwright/data/dashboard/get-projects-deleted.json new file mode 100644 index 0000000000..bb1fe484ee --- /dev/null +++ b/frontend/playwright/data/dashboard/get-projects-deleted.json @@ -0,0 +1,32 @@ +[ + { + "~:id": "~ue5a24d1b-ef1e-812f-8004-52bab84be6f7", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1715266551088", + "~:modified-at": "~m1715266551088", + "~:is-default": false, + "~:name": "New Project 1", + "~:is-pinned": false, + "~:count": 1 + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1713533116382", + "~:modified-at": "~m1713873823633", + "~:is-default": true, + "~:name": "Drafts", + "~:count": 1 + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920c", + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:created-at": "~m1715266551088", + "~:modified-at": "~m1715266551088", + "~:is-default": false, + "~:name": "Old Project", + "~:is-pinned": false, + "~:count": 1, + "~:deleted-at": "~m1732111500000" + } +] diff --git a/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json b/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json new file mode 100644 index 0000000000..d85f16b500 --- /dev/null +++ b/frontend/playwright/data/dashboard/get-team-deleted-files-pending.json @@ -0,0 +1,47 @@ +[ + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41234", + "~:revn": 1, + "~:vern": 1, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1705307400000", + "~:modified-at": "~m1732111500000", + "~:deleted-at": "~m1732111500000", + "~:name": "Deleted Design File 1", + "~:is-shared": false, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 1, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41235", + "~:revn": 2, + "~:vern": 2, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1704875700000", + "~:modified-at": "~m1732025400000", + "~:deleted-at": "~m1732025400000", + "~:name": "Deleted Design File 2", + "~:is-shared": true, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 2, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + }, + { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f41236", + "~:revn": 3, + "~:vern": 3, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920c", + "~:created-at": "~m1706792400000", + "~:modified-at": "~m1731939600000", + "~:deleted-at": "~m1731939600000", + "~:name": "Old Project Design", + "~:is-shared": false, + "~:will-be-deleted-at": "~m4102444800000", + "~:thumbnail-id": null, + "~:row-num": 3, + "~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d" + } +] diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 6e5a868789..8d5f9fca74 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -439,6 +439,78 @@ test.describe("Subscription controls", () => { }); }); +test.describe("Dashboard list controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Trash project options", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Deleted files due in 2100, so the trash keeps them. + await dashboardPage.mockRPC( + "get-team-deleted-files?team-id=*", + "dashboard/get-team-deleted-files-pending.json", + ); + // A deleted project, so its row shows the options button. + await dashboardPage.mockRPC( + "get-projects?team-id=*", + "dashboard/get-projects-deleted.json", + ); + await dashboardPage.goToDeleted(); + + const options = page.getByTestId("project-options").first(); + await expect(options).toBeVisible(); + const row = page.locator("[class*='dashboard-project-row']").first(); + await page.mouse.move(640, 700); + await expect(row.locator("header")).toHaveScreenshot(shot); + await options.hover(); + await expect(row.locator("header")).toHaveScreenshot(shot); + }); + + test("Empty project create button", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupNewProject(); + await dashboardPage.goToDashboard(); + await expect(dashboardPage.projectName).toBeVisible(); + + const create = page.locator("[class*='create-new']").first(); + await expect(create).toBeVisible(); + await page.mouse.move(640, 700); + await expect(create).toHaveScreenshot(shot); + await create.hover(); + await expect(create).toHaveScreenshot(shot); + }); + + test("Project rename", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + + await page.getByTestId("project-options").first().click(); + await page.getByText("Rename", { exact: true }).click(); + const edit = page.locator("[class*='edit-wrapper']").first(); + await expect(edit).toBeVisible(); + await expect(edit).toHaveScreenshot(shot); + }); + + test("Comments placeholder", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.mockRPC( + "get-unread-comment-threads?team-id=*", + "logged-in-user/get-unread-comment-threads-empty.json", + ); + await dashboardPage.goToDashboard(); + + await page.getByTestId("open-comments").click(); + const placeholder = page.locator("[class*='thread-groups-placeholder']"); + await expect(placeholder).toBeVisible(); + await expect(placeholder).toHaveScreenshot(shot); + // The whole dropdown, with the header title. + await expect( + page.locator("[class*='comment-threads-section']"), + ).toHaveScreenshot(shot); + }); +}); + test.describe("Team modals controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index 7f0da6b00e..8f2d4b14a8 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -15,14 +15,10 @@ [app.main.ui.comments :as cmt] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private comments-icon-svg - (deprecated-icon/icon-xref :comments (stl/css :comments-icon))) - (mf/defc comments-icon* [{:keys [profile on-show-comments]}] @@ -116,5 +112,7 @@ :key (:page-id tgroup)}])] [:div {:class (stl/css :thread-groups-placeholder)} - comments-icon-svg + [:> icon* {:icon-id i/comments + :size "l" + :class (stl/css :comments-icon)}] (tr "labels.no-comments-available")])]]])) diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss index 4c2b2b86a5..a703749b82 100644 --- a/frontend/src/app/main/ui/dashboard/comments.scss +++ b/frontend/src/app/main/ui/dashboard/comments.scss @@ -4,44 +4,39 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .dashboard-comments-section { - @include deprecated.flex-center; - + display: flex; + justify-content: center; + align-items: center; position: relative; - border-radius: deprecated.$br-8; + border-radius: $br-8; } .thread-groups { - height: calc(100% - deprecated.$s-32); - max-height: deprecated.$s-440; + block-size: calc(100% - #{$sz-32}); + max-block-size: px2rem(440); overflow: auto; } -.thread-group { - display: flex; - flex-direction: column; - font-size: deprecated.$fs-12; -} - .thread-groups-placeholder { align-items: center; display: flex; flex-direction: column; - font-size: deprecated.$fs-12; - padding: deprecated.$s-24; + font-size: px2rem(12); + padding: var(--sp-xxl); text-align: center; color: var(--color-foreground-secondary); } .comments-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); - height: deprecated.$s-24; - width: deprecated.$s-24; - margin-bottom: deprecated.$s-24; + flex-shrink: 0; + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .comment-button { @@ -49,57 +44,44 @@ .unread { position: absolute; - width: deprecated.$s-8; - height: deprecated.$s-8; - border: deprecated.$s-2 solid var(--color-background-tertiary); + inline-size: px2rem(8); + block-size: px2rem(8); + border: $b-2 solid var(--color-background-tertiary); border-radius: 50%; background: var(--color-foreground-error); - top: deprecated.$s-6; - right: deprecated.$s-6; + inset-block-start: px2rem(6); + inset-inline-end: px2rem(6); } } -.comments-icon-small { - @extend %button-icon; - - stroke: var(--comment-icon-small-foreground-color); -} - .dropdown { - @include deprecated.menu-shadow; - + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); background-color: var(--color-background-tertiary); - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid transparent; - bottom: deprecated.$s-4; - height: 40vh; - max-height: deprecated.$s-480; - min-height: deprecated.$s-200; + border-radius: $br-8; + border: $b-1 solid transparent; + inset-block-end: var(--sp-xs); + block-size: 40vh; + max-block-size: $sz-480; + min-block-size: $sz-200; position: absolute; overflow: hidden; - width: 100%; - z-index: deprecated.$z-index-4; - - hr { - margin: 0; - border-color: var(--color-foreground-secondary); - } + inline-size: 100%; + z-index: var(--z-index-dropdown); } .header { display: flex; - height: deprecated.$s-40; + block-size: $sz-40; align-items: center; - padding: 0 deprecated.$s-12; - gap: deprecated.$s-4; + padding: 0 var(--sp-m); + gap: var(--sp-xs); } .header-title { + @include use-typography("headline-small"); + color: var(--color-foreground-secondary); - font-size: deprecated.$fs-11; - line-height: 1.28; flex-grow: 1; - text-transform: uppercase; } .notifications-button { @@ -108,7 +90,7 @@ } &.mark-all-as-read-button { - border-radius: deprecated.$s-8; - border: deprecated.$s-1 solid; + border-radius: $br-8; + border: $b-1 solid; } } diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 6bef519d98..d2f8420121 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -21,9 +21,10 @@ [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -33,9 +34,6 @@ (def ^:private ref:deleted-files (l/derived :deleted-files st/state)) -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - (defn- on-restore-project [project] (let [on-accept #(st/emit! (dd/restore-project-immediately project))] @@ -158,13 +156,14 @@ [:div {:class (stl/css :info-wrapper)} [:div {:class (stl/css-case :project-actions true)} - [:button {:class (stl/css :options-btn) - :on-click on-menu-click - :title (tr "dashboard.options") - :aria-label (tr "dashboard.options") - :data-testid "project-options" - :on-key-down handle-menu-click} - menu-icon]] + [:> icon-button* {:variant "ghost" + :class (stl/css :options-btn) + :icon i/menu + :aria-label (tr "dashboard.options") + :aria-pressed (boolean (:menu-open @local)) + :data-testid "project-options" + :on-click on-menu-click + :on-key-down handle-menu-click}]] (when (:menu-open @local) [:> project-context-menu* diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index d6e9b376ea..7b9db012d6 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -4,19 +4,21 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/z-index.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .dashboard-container { flex: 1 0 0; - width: 100%; + inline-size: 100%; margin-inline-end: var(--sp-l); - border-top: $b-1 solid var(--panel-border-color); + border-block-start: $b-1 solid var(--color-background-quaternary); overflow-y: auto; padding-block-end: var(--sp-xxxl); } @@ -31,10 +33,10 @@ display: block; color: var(--color-foreground-secondary); - @include t.use-typography("body-large"); + @include use-typography("body-large"); line-height: 0.8; - height: var(--sp-xl); + block-size: var(--sp-xl); } .info-text-highlight { @@ -43,7 +45,7 @@ .deleted-options { display: flex; - gap: 5px; + gap: px2rem(5); flex-shrink: 0; } @@ -51,12 +53,12 @@ background: var(--color-background-default); padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); position: sticky; - top: 0; + inset-block-start: 0; z-index: var(--z-index-panels); } .nav-inside { - border-bottom: $b-1 solid var(--panel-border-color); + border-block-end: $b-1 solid var(--color-background-quaternary); display: flex; gap: var(--sp-l); justify-content: space-between; @@ -74,7 +76,7 @@ .selected { color: var(--color-foreground-primary); - border-bottom: $b-1 solid var(--color-foreground-primary); + border-block-end: $b-1 solid var(--color-foreground-primary); } .project { @@ -83,8 +85,8 @@ align-items: center; justify-content: space-between; gap: var(--sp-s); - width: 99%; - max-height: $sz-40; + inline-size: 99%; + max-block-size: $sz-40; padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-l); margin-block-start: var(--sp-l); border-radius: $br-4; @@ -94,19 +96,19 @@ display: flex; align-items: center; justify-content: flex-start; - width: 100%; - min-height: var(--sp-xxxl); + inline-size: 100%; + min-block-size: var(--sp-xxxl); margin-inline-start: var(--sp-s); } .project-name { - @include t.use-typography("body-large"); + @include use-typography("body-large"); - width: fit-content; + inline-size: fit-content; margin-inline-end: var(--sp-m); line-height: 0.8; - color: var(--title-foreground-color-hover); - height: var(--sp-l); + color: var(--color-foreground-primary); + block-size: var(--sp-l); } .project-actions { @@ -115,14 +117,8 @@ margin-inline-start: var(--sp-xxxl); } -.add-file-btn, .options-btn { - @extend %button-tertiary; - - height: var(--sp-xxxl); - width: var(--sp-xxxl); margin: 0 var(--sp-s); - padding: var(--sp-s); } .info-wrapper { @@ -130,10 +126,3 @@ align-items: center; gap: var(--sp-s); } - -.add-icon, -.menu-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); -} diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 347364ff67..a536033e4b 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -30,7 +30,6 @@ [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.jobs.progress :as jp] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -356,7 +355,7 @@ [:span {:class (stl/css-case :linked-library-tag true :error error?)} - deprecated-icon/detach]])))]])) + [:> icon* {:icon-id i/detach}]]])))]])) (defn initialize-state [entries] diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.cljs b/frontend/src/app/main/ui/dashboard/inline_edition.cljs index 18f5930c60..c3686d3147 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.cljs +++ b/frontend/src/app/main/ui/dashboard/inline_edition.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.dashboard.inline-edition (:require-macros [app.main.style :as stl]) (:require - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.keyboard :as kbd] [rumext.v2 :as mf])) @@ -68,7 +67,5 @@ :on-change on-input :on-key-down on-keyup :on-blur on-blur - :max-length max-length}] - [:span {:class (stl/css :close) - :on-click on-cancel} deprecated-icon/close]])) + :max-length max-length}]])) diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.scss b/frontend/src/app/main/ui/dashboard/inline_edition.scss index 0414167696..1310bd2925 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.scss +++ b/frontend/src/app/main/ui/dashboard/inline_edition.scss @@ -4,50 +4,31 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; .edit-wrapper { - border-radius: deprecated.$br-4; + border-radius: $br-4; display: flex; - padding-right: deprecated.$s-24; + padding-inline-end: var(--sp-xxl); position: relative; - margin-right: deprecated.$s-24; + margin-inline-end: var(--sp-xxl); } -input.element-title { - background-color: var(--input-background-color-active); - border-radius: deprecated.$br-8; +.element-title { + background-color: var(--color-background-primary); + border-radius: $br-8; color: var(--color-foreground-primary); - font-size: deprecated.$fs-16; - height: deprecated.$s-32; + font-size: px2rem(16); + block-size: $sz-32; margin: 0; border: none; - padding: deprecated.$s-6; - width: 100%; + padding: px2rem(6); + inline-size: 100%; &:focus-visible { - border: deprecated.$s-1 solid var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); outline: none; } } - -.close { - cursor: pointer; - position: absolute; - top: deprecated.$s-1; - right: calc(-1 * deprecated.$s-8); - - svg { - fill: var(--color-foreground-secondary); - height: deprecated.$s-16; - transform: rotate(45deg) translateY(7px); - width: deprecated.$s-16; - margin: 0; - } - - &:hover { - svg { - fill: var(--element-foreground-warning); - } - } -} diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index 100f1ed4f3..815c9d04b2 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -10,9 +10,9 @@ [app.main.data.event :as ev] [app.main.store :as st] [app.main.ui.dashboard.import :as udi] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -131,7 +131,7 @@ :on-mouse-leave on-mouse-leave} (if show-text? (tr "dashboard.empty-project.create") - deprecated-icon/add)]])))) + [:> icon* {:icon-id i/add :size "xl"}])]])))) (mf/defc loading-placeholder* [] diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index bce8ccc0d6..11d613f907 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -12,8 +12,8 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.grid :refer [grid*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -62,17 +62,20 @@ (cond (empty? search-term) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.type-something")]] (nil? result) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.searching-for" search-term)]] (empty? result) [:div {:class (stl/css :grid-empty-placeholder :search)} - [:div {:class (stl/css :icon)} deprecated-icon/search] + [:div {:class (stl/css :icon)} + [:> icon* {:icon-id i/search :size "xl"}]] [:div {:class (stl/css :text)} (tr "dashboard.no-matches-for" search-term)]] :else From 980ed0a719c0deb7630eba7ffe83747080eb7eee Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 12:11:06 +0100 Subject: [PATCH 182/325] :recycle: Use DS icon buttons to close the subscription dialogs (#12539) * :recycle: Use DS icon buttons to close the subscription dialogs The close buttons of the five subscription dialogs (plan management, success, Nitrate subscription, contact sales and manual cancel) were raw buttons with no name. They become ghost DS icon buttons named "Close"; the class that places them in the corner goes on the tooltip wrapper, so they stay where they were and only show the DS hover. The plans page link-style buttons stay raw: they have no DS equivalent. A visual test covers the plans page and the plan management dialog. AI-assisted-by: claude-opus-5-5 * :recycle: Move settings subscription styles closer to the DS The settings subscription stylesheet inlines the deprecated button-style mixin and %button-icon placeholder, resolves its legacy color aliases and font weight, uses logical properties and the DS typography module without a namespace. The rules on icon svgs that have no class keep a comment; two that match nothing (the dropdown arrow svg and .radio-btns) are commented out with a TODO. refactor/ stays only for the modal placeholders (%modal-*), which move with the modals refactor. No visual change. AI-assisted-by: claude-opus-5-5 * :fire: Remove unused styles from the settings subscription The SCSS refactor of the settings subscription left rules commented out with a TODO because the module does not use them, or because they target an svg inside a DS icon that is the svg itself. CSS modules scope the classes to this module and no other source uses them, so they go: .icon-dropdown svg, .radio-btns, .radio-btns label. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Give the subscription icons classes and drop unused props Review fixes for the subscription styles: - The plan icon span sets the letter color with color, not stroke on a bare svg, and drops fill, stroke-width and color: transparent, which do nothing on a span. - The plan icon border uses $b-2 (was 1.75px), so it is 0.25px thicker. - The letter gets its own class for its 2.25px stroke. With the default stroke it looks too thin next to the border. - The open link and editors arrow icons get a cta-icon class instead of the bare svg selector, and take the button color. The arrow used an icon-dropdown class with no rule. AI-assisted-by: claude-opus-5-5 * :recycle: Use the DS stroke width and color in the subscription icons The plan letter and the member icons keep the DS stroke width (the plan letter line gets thinner), the member icons set their color instead of the stroke, and the props copied from the button icon placeholder go. The success dialog logo gets its own class instead of a bare svg selector. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 37 ++++++ .../app/main/ui/settings/subscription.cljs | 47 +++++--- .../app/main/ui/settings/subscription.scss | 108 +++++++----------- 3 files changed, 109 insertions(+), 83 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 8d5f9fca74..2e3b7fe082 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -559,6 +559,43 @@ test.describe("Team modals controls", () => { }); }); +test.describe("Settings subscription controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Plans page and management dialog", async ({ page }) => { + await DashboardPage.mockConfigFlags( + page, + ["enable-subscriptions", "disable-onboarding"], + { penpotIsSaas: true }, + ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await page.goto("/?screen=settings-subscription"); + await expect( + page.getByRole("heading", { name: "Subscription", level: 2 }), + ).toBeVisible(); + + const trial = page + .getByRole("button", { name: "Try it free for 14 days" }) + .first(); + await expect(trial).toBeVisible(); + await page.mouse.move(10, 700); + await expect(page).toHaveScreenshot(shot); + + await trial.click(); + const dialog = page.locator("[class*='modal-dialog']"); + await expect(dialog).toBeVisible(); + await page.mouse.move(10, 700); + await expect(dialog).toHaveScreenshot(shot); + + await dialog.locator("button").first().hover(); + await expect(dialog).toHaveScreenshot(shot); + }); +}); + test.describe("Dashboard sidebar controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index d1db4cab5f..3b879563a9 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -16,6 +16,7 @@ [app.main.ui.components.forms :as fm] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] [app.main.ui.nitrate.nitrate-activation-success-modal] @@ -93,6 +94,7 @@ [:button {:class (stl/css :cta-button :more-info) :on-click cta-link-with-icon} cta-text-with-icon [:> icon* {:icon-id "open-link" + :class (stl/css :cta-icon) :size "s"}]]) (when has-cta-link [:button {:class (stl/css-case :cta-button true @@ -137,7 +139,7 @@ [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} (tr "subscription.settings.management.dialog.editors") [:> icon* {:icon-id (if show-editors-list i/arrow-up i/arrow-down) - :class (stl/css :icon-dropdown) + :class (stl/css :cta-icon) :size "s"}]] (when show-editors-list [:* @@ -262,9 +264,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (if (= unlimited-modal-step 2) (tr "subscription.settings.management-dialog.step-2-title") @@ -382,12 +386,15 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :subscription-success)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-success-content)} [:div {:class (stl/css :modal-start)} - [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]] + [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription") + :class (stl/css :modal-start-logo)}]] [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} @@ -835,9 +842,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title :nitrate-subscription)} (tr "nitrate.form.title")] @@ -889,9 +898,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (tr "nitrate.contact-sales.title" subscription-type)] [:div {:class (stl/css :modal-content)} @@ -942,9 +953,11 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} - [:> icon* {:icon-id "close" - :size "m"}]] + [:> icon-button* {:variant "ghost" + :tooltip-class (stl/css :close-btn) + :aria-label (tr "labels.close") + :icon i/close + :on-click handle-close-dialog}] [:div {:class (stl/css :modal-title :subscription-title)} (tr "nitrate.subscription.settings.manual-cancel")] diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index ff7885506d..b1fd03d2b6 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -4,8 +4,10 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// refactor/ stays for the modal placeholders (%modal-*), which move with +// the modals refactor. @use "refactor/common-refactor.scss" as deprecated; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; @use "ds/_sizes.scss" as *; @@ -39,34 +41,29 @@ } .membership-date { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); } -.subscription-member, .penpot-member { - @extend %button-icon; -} - -.penpot-member { - stroke: var(--icon-foreground); + color: var(--color-foreground-secondary); } .subscription-member { - stroke: var(--color-badge-premium); + color: var(--color-badge-premium); } .title-section { - @include t.use-typography("title-large"); + @include use-typography("title-large"); color: var(--color-foreground-primary); margin-block-end: var(--sp-xxl); } .plan-section-title { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); color: var(--color-foreground-primary); } @@ -100,29 +97,26 @@ } .plan-title-icon { - @extend %button-icon; - + display: flex; + justify-content: center; + align-items: center; + color: var(--color-foreground-primary); block-size: var(--sp-xl); inline-size: var(--sp-xl); - border-radius: 6px; - border: 1.75px solid var(--color-foreground-primary); -} - -.plan-title-icon svg { - stroke: var(--color-foreground-primary); - stroke-width: 2.25px; + border-radius: $br-6; + border: $b-2 solid var(--color-foreground-primary); } .plan-card-title, .plan-price-value, .price-value { - @include t.use-typography("title-medium"); + @include use-typography("title-medium"); color: var(--color-foreground-primary); } .plan-editors { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-self: end; color: var(--color-foreground-primary); @@ -130,7 +124,7 @@ } .plan-price-period { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-primary); } @@ -144,7 +138,7 @@ } .plan-cancel-date { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-primary); } @@ -158,7 +152,7 @@ .benefits-title, .benefit { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } @@ -172,29 +166,24 @@ } .cta-button { - @include t.use-typography("body-medium"); - @include deprecated.button-style; + @include use-typography("body-medium"); + border: none; + background: none; + cursor: pointer; align-items: center; color: var(--color-accent-primary); display: flex; margin-block-start: var(--sp-m); - text-align: left; + text-align: start; } -.cta-button svg { - @extend %button-icon; - +.cta-icon { block-size: var(--sp-l); inline-size: var(--sp-l); - stroke: var(--color-accent-primary); margin-inline-start: var(--sp-xs); } -.icon-dropdown svg { - transform: rotate(90deg); -} - .bottom-link { margin-block-start: var(--sp-xs); } @@ -204,7 +193,7 @@ } .inline-error { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-error); margin-block: var(--sp-s) 0; @@ -225,15 +214,18 @@ min-inline-size: px2rem(648); } +// Tooltip wrapper of the close buttons: it places them in the corner. .close-btn { - @extend %modal-close-btn-base; + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-xs); } .modal-title { - @include t.use-typography("title-large"); + @include use-typography("title-large"); margin-block-end: var(--sp-xxxl); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); display: flex; gap: var(--sp-m); } @@ -243,7 +235,7 @@ } .modal-text-medium { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); } .modal-text-cap { @@ -257,7 +249,7 @@ .modal-text-medium strong, .text-strikethrough strong, .modal-text-cap strong { - font-weight: deprecated.$fw700; + font-weight: 700; } .modal-content, @@ -309,13 +301,13 @@ } } -.modal-start svg { +.modal-start-logo { inline-size: 100%; block-size: auto; } .editors-text { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin: 0; } @@ -326,7 +318,7 @@ } .editors-list { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); list-style-position: inside; list-style-type: none; @@ -342,7 +334,7 @@ } .error-message { - @include t.use-typography("body-small"); + @include use-typography("body-small"); color: var(--color-foreground-error); margin-block-start: var(--sp-s); @@ -361,7 +353,7 @@ } .unlimited-capped-warning { - @include t.use-typography("body-small"); + @include use-typography("body-small"); background-color: var(--color-background-tertiary); border-radius: var(--sp-s); @@ -374,28 +366,12 @@ padding-inline: 0; } -.radio-btns { - display: flex; - flex-direction: column; - padding-block-end: var(--sp-xl); - gap: var(--sp-s); -} - -.radio-btns label { - @include t.use-typography("body-large"); - - padding: 0; - display: flex; - align-items: center; - color: var(--color-foreground-secondary); -} - .modal-contact-content { gap: var(--sp-xl); } .downgrade-warning { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); background-color: var(--color-background-tertiary); border-radius: var(--sp-s); @@ -412,13 +388,13 @@ } .downgrade-item { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); margin-block-end: var(--sp-l); } .cta-link { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-items: center; color: var(--color-accent-primary); From da36e3683649b536a8d255cb8fb314c08f4361dc Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 14:26:43 +0100 Subject: [PATCH 183/325] :sparkles: Add the share, handlers and snap icons to the DS (#12659) * :sparkles: Add the share, handlers and snap icons to the DS The share button and the path editing bar could not move to the DS buttons because their icons were missing from the DS icon set. As agreed with design: - handlers-equal, handlers-independent, handlers-mirror and snap join the DS as they are (their SVGs already follow the DS format). - share keeps its drawing, moved onto the 16x16 grid as a single path with no fixed color. - snap-nodes is deleted: nothing used it, and snap is the icon of the snap nodes button. The legacy share button in the workspace header uses the same SVG: its icon is about 6% smaller and now takes the hover color, which the fixed stroke color blocked before. AI-assisted-by: claude-opus-5-5 * :bug: Show the share icon lines outside the app The share SVG had no fill or stroke, so plain SVG viewers used the defaults (black fill, no stroke) and drew only three dots. Set fill="none" and stroke="currentColor", like settings.svg. The app already sets both in CSS, so nothing changes there. AI-assisted-by: claude-opus-5-5 * :bug: Show the handlers and snap icons right outside the app Like share, these SVGs had no fill or stroke, so plain SVG viewers filled the shapes black and drew no lines. Set fill="none" and stroke="currentColor". The app sets both in CSS, so nothing changes there. AI-assisted-by: claude-opus-5-5 --- frontend/resources/images/icons/handlers-equal.svg | 2 +- frontend/resources/images/icons/handlers-independent.svg | 2 +- frontend/resources/images/icons/handlers-mirror.svg | 2 +- frontend/resources/images/icons/share.svg | 4 +++- frontend/resources/images/icons/snap-nodes.svg | 3 --- frontend/resources/images/icons/snap.svg | 2 +- frontend/src/app/main/ui/ds/foundations/assets/icon.cljs | 6 +++++- frontend/src/app/main/ui/icons.cljs | 1 - 8 files changed, 12 insertions(+), 10 deletions(-) delete mode 100644 frontend/resources/images/icons/snap-nodes.svg diff --git a/frontend/resources/images/icons/handlers-equal.svg b/frontend/resources/images/icons/handlers-equal.svg index 645e773d8c..c1b3cb33aa 100644 --- a/frontend/resources/images/icons/handlers-equal.svg +++ b/frontend/resources/images/icons/handlers-equal.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/handlers-independent.svg b/frontend/resources/images/icons/handlers-independent.svg index 36e1c06917..cb782fbfe2 100644 --- a/frontend/resources/images/icons/handlers-independent.svg +++ b/frontend/resources/images/icons/handlers-independent.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/handlers-mirror.svg b/frontend/resources/images/icons/handlers-mirror.svg index 78f71c740e..1e8ac8868c 100644 --- a/frontend/resources/images/icons/handlers-mirror.svg +++ b/frontend/resources/images/icons/handlers-mirror.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/resources/images/icons/share.svg b/frontend/resources/images/icons/share.svg index 2c0d41de63..0677a583d3 100644 --- a/frontend/resources/images/icons/share.svg +++ b/frontend/resources/images/icons/share.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/frontend/resources/images/icons/snap-nodes.svg b/frontend/resources/images/icons/snap-nodes.svg deleted file mode 100644 index 6ce0a90d67..0000000000 --- a/frontend/resources/images/icons/snap-nodes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/resources/images/icons/snap.svg b/frontend/resources/images/icons/snap.svg index 0acdce1f20..b2f88cb835 100644 --- a/frontend/resources/images/icons/snap.svg +++ b/frontend/resources/images/icons/snap.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 991532c963..6d05480deb 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -165,6 +165,9 @@ (def ^:icon-id group "group") (def ^:icon-id gutter-horizontal "gutter-horizontal") (def ^:icon-id gutter-vertical "gutter-vertical") +(def ^:icon-id handlers-equal "handlers-equal") +(def ^:icon-id handlers-independent "handlers-independent") +(def ^:icon-id handlers-mirror "handlers-mirror") (def ^:icon-id help "help") (def ^:icon-id hide "hide") (def ^:icon-id history "history") @@ -238,10 +241,11 @@ (def ^:icon-id search "search") (def ^:icon-id separate-nodes "separate-nodes") (def ^:icon-id settings "settings") +(def ^:icon-id share "share") (def ^:icon-id shown "shown") (def ^:icon-id size-horizontal "size-horizontal") (def ^:icon-id size-vertical "size-vertical") -(def ^:icon-id snap-nodes "snap-nodes") +(def ^:icon-id snap "snap") (def ^:icon-id status-alert "status-alert") (def ^:icon-id status-tick "status-tick") (def ^:icon-id status-update "status-update") diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 7dec276e5d..59708eef1a 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -224,7 +224,6 @@ (def ^:icon size-horizontal (icon-xref :size-horizontal)) (def ^:icon size-vertical (icon-xref :size-vertical)) (def ^:icon snap (icon-xref :snap)) -(def ^:icon snap-nodes (icon-xref :snap-nodes)) (def ^:icon status-alert (icon-xref :status-alert)) (def ^:icon status-tick (icon-xref :status-tick)) (def ^:icon status-update (icon-xref :status-update)) From e308c2eca6d0c26d6746594592148fe8db5c343d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 14:27:38 +0100 Subject: [PATCH 184/325] :recycle: Use the DS stroke width in the migrated DS icons (#12673) * :recycle: Use the DS stroke width in the migrated DS icons The DS icons in the areas already moved to the DS set their own stroke width (1px, 1.33px or 2.25px) to match the legacy icons. They now keep the DS stroke width, so some icon lines get slightly thinner or thicker: copy button, context menu, layer row, comments filter, assets section, share link, dashboard sidebar, team pages, templates, team switcher and subscription icons. AI-assisted-by: claude-opus-5-5 * :recycle: Give the share link page rows a class The page rows of the share link permissions get their own class instead of a bare li selector. No visual change. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/components/copy_button.scss | 2 -- .../src/app/main/ui/components/editable_select.scss | 1 - .../main/ui/dashboard/organization_team_switch.scss | 3 --- frontend/src/app/main/ui/dashboard/sidebar.scss | 3 --- frontend/src/app/main/ui/dashboard/subscription.scss | 1 - frontend/src/app/main/ui/dashboard/team.scss | 6 ------ frontend/src/app/main/ui/dashboard/templates.scss | 2 -- frontend/src/app/main/ui/viewer/share_link.cljs | 2 +- frontend/src/app/main/ui/viewer/share_link.scss | 10 ++++------ frontend/src/app/main/ui/workspace/comments.scss | 2 -- frontend/src/app/main/ui/workspace/context_menu.scss | 1 - frontend/src/app/main/ui/workspace/sidebar/assets.scss | 1 - .../src/app/main/ui/workspace/sidebar/layer_item.scss | 5 ----- 13 files changed, 5 insertions(+), 34 deletions(-) diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 8ae7cbf664..cecd60f2f1 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -28,7 +28,6 @@ .icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); } } @@ -81,7 +80,6 @@ .icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-primary); display: none; } diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 7257ee14f2..fc5bc19cf8 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -123,7 +123,6 @@ justify-content: center; align-items: center; fill: none; - stroke-width: 1.33px; visibility: hidden; color: var(--dropdown-element-icon-color); } diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss index 3fee83b7a4..bba4da94e7 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -80,7 +80,6 @@ width: $sz-16; color: transparent; fill: none; - stroke-width: 1px; stroke: var(--icon-foreground); } @@ -127,7 +126,6 @@ .chevron-icon svg { color: transparent; fill: none; - stroke-width: 1px; stroke: var(--icon-foreground); } @@ -416,7 +414,6 @@ width: $sz-16; color: transparent; fill: none; - stroke-width: 1px; stroke: var(--sidebar-action-icon-color); } diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 424f56427b..21a5c112bf 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -147,7 +147,6 @@ // side keeps the drawing and the text where they were. .pin-icon { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); margin: 0 px2rem(10); } @@ -213,7 +212,6 @@ .search-icon { flex-shrink: 0; - stroke-width: 1px; } .clear-search-btn:hover { @@ -374,6 +372,5 @@ } .exit-icon { - stroke-width: 1px; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 6cfe4c2e9a..e90a800019 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -167,7 +167,6 @@ .subscription-icon { stroke: var(--color-foreground-secondary); - stroke-width: 2.25px; } .menu-item { diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index b51bd9d071..2720ab6d58 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -78,7 +78,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; margin: 0 auto; color: var(--color-foreground-secondary); } @@ -125,7 +124,6 @@ } .image-icon { - stroke-width: $b-1; color: var(--color-foreground-primary); } @@ -571,7 +569,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-accent-success); } @@ -581,7 +578,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-accent-warning); } @@ -641,7 +637,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } @@ -976,7 +971,6 @@ align-items: center; block-size: $sz-16; inline-size: $sz-16; - stroke-width: $b-1; color: var(--stroke-color); } diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 1be61d4465..b9ae6c92f2 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -105,7 +105,6 @@ align-items: center; height: $sz-16; width: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } @@ -241,7 +240,6 @@ align-items: center; height: $sz-16; width: $sz-16; - stroke-width: $b-1; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 32e9117908..6d43e1c60b 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -283,7 +283,7 @@ [:ul {:class (stl/css :pages-selection)} (for [{:keys [id name]} pages] - [:li {:class (stl/css :checkbox-wrapper) + [:li {:class (stl/css :checkbox-wrapper :page-row) :key (dm/str id)} [:label {:for (dm/str "page-" id)} [:span {:class (stl/css-case :global/checked (contains? selected id))} diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index 336ad5ede3..7cb4e94245 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -136,7 +136,6 @@ .arrow-icon { flex-shrink: 0; - stroke-width: 1px; color: var(--color-foreground-secondary); } @@ -187,13 +186,12 @@ .pages-selection { margin: 0; +} - // The page rows have no class of their own. - li { - border-block-end: $b-1 solid var(--color-background-quaternary); - } +.page-row { + border-block-end: $b-1 solid var(--color-background-quaternary); - li:last-child { + &:last-child { border-block-end: none; } } diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index 9e7471e394..ea18d51a42 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -68,7 +68,6 @@ .arrow-svg { flex-shrink: 0; - stroke-width: 1.33px; color: var(--color-foreground-secondary); } } @@ -105,7 +104,6 @@ inline-size: $sz-16; block-size: $sz-16; stroke: currentcolor; - stroke-width: 1.33px; color: transparent; } } diff --git a/frontend/src/app/main/ui/workspace/context_menu.scss b/frontend/src/app/main/ui/workspace/context_menu.scss index 3061e59a4b..e46fae5ace 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/context_menu.scss @@ -127,7 +127,6 @@ align-items: center; inline-size: $sz-16; color: var(--color-foreground-primary); - stroke-width: 1.33px; } .separator { diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index afccd3404d..97fb0733ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -41,7 +41,6 @@ .section-icon { flex-shrink: 0; - stroke-width: 1px; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index d7cbad6289..18a1395d52 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -211,8 +211,6 @@ } .toggle-content-icon { - // Same stroke weight as the old 12px icons. - stroke-width: 1.33px; color: var(--color-foreground-secondary); .layer-row.hidden & { @@ -284,9 +282,6 @@ // The DS icon box is 16px and the button leaves 12px: keep the box // whole, centered, instead of letting it shrink and cut the icon. flex-shrink: 0; - - // Same stroke weight as the old 12px icons. - stroke-width: 1.33px; color: var(--color-foreground-secondary); .layer-row.hidden & { From 66c67660f94b36e9d47f5f88619723d16860c7dc Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 14:27:51 +0100 Subject: [PATCH 185/325] :recycle: Use DS typographies in the debug shape panel and drop a dead selector (#12650) * :fire: Remove a commented-out selector from the debug shape panel .shape-info-title was left commented out with a "check by hand" TODO in #12484. Nothing uses it, so delete it instead of keeping it as a comment. AI-assisted-by: claude-opus-5-5 * :recycle: Use DS typographies in the debug shape panel The panel text and the shape title only set a font size, with a "no exact DS typography match" TODO. body-small and body-medium differ from them only in the line-height, so use them directly and drop the TODOs. Visible change: the panel lines get the DS line-height (1.3 for the text, 1.4 for the shape title) instead of the browser default, so rows grow a little taller. The panel is debug-only. AI-assisted-by: claude-opus-5-5 --- .../ui/workspace/sidebar/debug_shape_info.scss | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index 9f770611f7..27ef6f4b32 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; +@use "ds/typography.scss" as t; @use "ds/_utils.scss" as *; .shape-info { @@ -13,26 +14,19 @@ background-color: var(--color-background-primary); color: var(--color-foreground-primary); - // TODO: No exact DS typography match found. - // Closest match: body-small. - font-size: px2rem(12); + @include t.use-typography("body-small"); + user-select: text; } -// TODO: Unused in debug_shape_info.cljs; check by hand before removing. -// .shape-info-title { -// margin: var(--sp-s) var(--sp-s) 0 var(--sp-s); -// } - .attrs-container { padding: var(--sp-l) var(--sp-s); overflow: hidden; } .shape-title { - // TODO: No exact DS typography match found. - // Closest match: body-medium. - font-size: px2rem(14); + @include t.use-typography("body-medium"); + background: var(--color-background-quaternary); color: var(--color-foreground-primary); padding: var(--sp-s); From 8951c57ee6157fcfb209b4c6569fe64014cf5037 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 28 Oct 2026 16:11:52 +0100 Subject: [PATCH 186/325] :recycle: Use DS buttons and icons in the dashboard fonts page (#12533) * :recycle: Use DS buttons and icons in the dashboard fonts page The fonts page moves off the deprecated icons and raw buttons: - "Add custom font", "Upload all", "Upload" and "Save" become DS primary buttons and "Dismiss all" a secondary one. - The remove, cancel and menu controls of the font rows become ghost DS icon buttons named "Remove", "Cancel" and "Options"; the menu one says whether its menu is open. Two of them were spans. - The delete control of a font style was a span; it is now a button named "Delete font style" with the DS close icon, still shown only on hover. - The warning and empty list icons use the DS icons. The loading placeholder keeps the legacy loader: there is no DS one. The font menu offset grows by the 6px the taller menu button added, so the menu opens where it did. New key: dashboard.fonts.delete-variant (en, es). A visual test covers a font ready to upload, the style hover, the font menu and the edition row. AI-assisted-by: claude-opus-5-5 * :recycle: Move dashboard fonts styles to logical properties Physical sizes, offsets, margins and paddings become logical properties and the DS typography module is used without a namespace. The element selectors (section title, inputs, hero text) stay, with a comment: they have no class. The shared dashboard header styles (common-dashboard) still come from refactor/: ten dashboard modules use them, so they move in their own change. No visual change. AI-assisted-by: claude-opus-5-5 * :recycle: Use the DS loader and heading in the dashboard fonts page The fonts loading placeholder used the legacy loader icon, drawn as an outline in a 32px box. It now uses the DS loader*, the same animated pencil as the other dashboard loading screens. Its box is 9px tall, so the label moves 6px up. The section title h3 is now a heading* with the title-small typography and its own class, so the SCSS no longer styles the bare element. The legacy loader icon has no other use, so its SVG and def are gone. AI-assisted-by: claude-opus-5-5 * :recycle: Give the dashboard fonts inputs and hero text classes The font name and search inputs and the hero text get their own classes instead of bare input and p selectors, and the hero title uses the DS heading with title-large instead of the global h2 rule. The title keeps its 24px size and weight; the DS line height (1.4 instead of 1.15) moves the hero content about 6px down. AI-assisted-by: claude-opus-5-5 * :bug: Give the fonts placeholder icon its color back Removing the legacy loader also removed the .icon svg rule, which gave the empty placeholder text icon its color, so the icon had none. The icon now has its own class with the secondary foreground color, and the wrapper div without styles goes. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 68 +++++ frontend/resources/images/icons/loader.svg | 1 - frontend/src/app/main/ui/dashboard/fonts.cljs | 133 ++++++---- frontend/src/app/main/ui/dashboard/fonts.scss | 247 ++++++++---------- frontend/src/app/main/ui/icons.cljs | 1 - frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 7 files changed, 265 insertions(+), 193 deletions(-) delete mode 100644 frontend/resources/images/icons/loader.svg diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 2e3b7fe082..878a02d347 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -338,6 +338,74 @@ test.describe("Delete account modal", () => { }); }); +test.describe("Fonts page controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Installed font variant, menu and edition", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + const installed = page.locator("[class*='dashboard-installed-fonts']"); + const row = installed.locator("[class*='font-item']").first(); + await expect(row).toBeVisible(); + + // The variant delete icon only shows on hover. + await row.locator("[class$='__variant'], [class*='__variant ']").hover(); + await expect(installed).toHaveScreenshot(shot); + + // The menu button: a span before the DS migration, a DS icon button + // named "Options" after it. + const options = row.locator("[class*='options']"); + await options + .locator("span[class*='icon']") + .or(options.getByRole("button", { name: "Options" })) + .first() + .click(); + await expect(page.getByText("Download")).toBeVisible(); + await expect(page).toHaveScreenshot(shot); + + await page.getByText("Edit", { exact: true }).click(); + await expect(row.locator("input")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(installed).toHaveScreenshot(shot); + }); + + test("Fonts ready to upload", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + await page + .locator("#font-upload") + .setInputFiles("playwright/data/render-wasm/assets/ebgaramond.ttf"); + await expect(page.getByTestId("upload-all")).toBeVisible(); + await page.mouse.move(10, 700); + await expect( + page.locator("[class*='dashboard-fonts-upload']"), + ).toHaveScreenshot(shot); + }); + + test("Fonts loading placeholder", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.setupDashboardFull(); + // Never answer, so the page keeps showing the loader. + await page.route(/get-font-variants/, () => {}); + await dashboardPage.goToDashboard(); + await dashboardPage.fontsLink.click(); + await expect(dashboardPage.mainHeading).toHaveText("Fonts"); + + const placeholder = page.locator("[class*='fonts-placeholder']"); + await expect(placeholder).toBeVisible(); + await page.mouse.move(10, 700); + await expect(placeholder).toHaveScreenshot(shot); + }); +}); + test.describe("Subscription controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/resources/images/icons/loader.svg b/frontend/resources/images/icons/loader.svg deleted file mode 100644 index ee1d9b96bf..0000000000 --- a/frontend/resources/images/icons/loader.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 5574f21572..ed27260fe9 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -21,8 +21,13 @@ [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.http :as http] @@ -195,13 +200,18 @@ [:div {:class (stl/css :dashboard-fonts-upload)} [:div {:class (stl/css :dashboard-fonts-hero)} [:div {:class (stl/css :desc)} - [:h2 (tr "labels.upload-custom-fonts")] - [:> i18n/tr-html* {:content (tr "dashboard.fonts.hero-text1")}] + [:> heading* {:level 2 + :typography t/title-large + :class (stl/css :hero-title)} + (tr "labels.upload-custom-fonts")] + [:> i18n/tr-html* {:content (tr "dashboard.fonts.hero-text1") + :class (stl/css :hero-text)}] - [:button {:class (stl/css :btn-primary) - :on-click on-click - :tab-index "0"} - [:span (tr "labels.add-custom-font")] + [:> button* {:variant "primary" + :type "button" + :class (stl/css :add-font-button) + :on-click on-click} + (tr "labels.add-custom-font") [:& file-uploader {:input-id "font-upload" :accept accept-font-types :multi true @@ -223,17 +233,19 @@ [:div {:class (stl/css :font-item :table-row)} [:span (tr "dashboard.fonts.fonts-added" (i18n/c (count fonts)))] [:div {:class (stl/css :table-field :options)} - [:button {:class (stl/css-case - :btn-primary true - :disabled disable-upload-all?) - :on-click on-upload-all - :data-testid "upload-all" - :disabled disable-upload-all?} - [:span (tr "dashboard.fonts.upload-all")]] - [:button {:class (stl/css :btn-secondary) - :on-click on-dismis-all - :data-testid "dismiss-all"} - [:span (tr "dashboard.fonts.dismiss-all")]]]]) + [:> button* {:variant "primary" + :type "button" + :class (stl/css :upload-button) + :on-click on-upload-all + :data-testid "upload-all" + :disabled disable-upload-all?} + (tr "dashboard.fonts.upload-all")] + [:> button* {:variant "secondary" + :type "button" + :class (stl/css :dismiss-button) + :on-click on-dismis-all + :data-testid "dismiss-all"} + (tr "dashboard.fonts.dismiss-all")]]]) (for [{:keys [id] :as item} (sort-by :font-family font-vals)] (let [uploading? (contains? uploading id) @@ -242,6 +254,7 @@ :key (dm/str id)} [:div {:class (stl/css :table-field :family)} [:input {:type "text" + :class (stl/css :name-input) :data-id (dm/str id) :on-blur on-blur-name :on-change on-change-name @@ -256,23 +269,23 @@ [:div {:class (stl/css :table-field :options)} (when (:height-warning? item) - [:span {:class (stl/css :icon :failure)} - deprecated-icon/msg-neutral]) + [:> icon* {:icon-id i/msg-neutral + :class (stl/css :warning-icon)}]) - [:button {:on-click on-upload - :data-id (dm/str id) - :class (stl/css-case - :btn-primary true - :upload-button true - :disabled disable-upload?) - :disabled disable-upload?} + [:> button* {:variant "primary" + :type "button" + :on-click on-upload + :data-id (dm/str id) + :class (stl/css :upload-button) + :disabled disable-upload?} (if ^boolean uploading? (tr "labels.uploading") (tr "labels.upload"))] - [:span {:class (stl/css :icon :close) - :data-id (dm/str id) - :on-click on-delete} - deprecated-icon/close]]]))]])) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.remove") + :icon i/close + :data-id (dm/str id) + :on-click on-delete}]]]))]])) (mf/defc installed-font-context-menu* {::mf/private true} @@ -292,7 +305,7 @@ :show is-open :fixed false :min-width true - :top -15 + :top -21 :left -115 :options options}])) @@ -406,6 +419,7 @@ :is-edition edition?)} (if ^boolean edition? [:input {:type "text" + :class (stl/css :name-input) :auto-focus true :default-value font-family :on-key-down on-key-down @@ -420,29 +434,37 @@ [:span {:class (stl/css :label)} [:> font-variant-display-name* {:variant item}]] (when can-edit - [:span - {:class (stl/css :icon :close) - :data-id (dm/str id) - :on-click on-delete-variant} - deprecated-icon/add])])] + [:button {:type "button" + :class (stl/css :variant-delete) + :aria-label (tr "dashboard.fonts.delete-variant") + :title (tr "dashboard.fonts.delete-variant") + :data-id (dm/str id) + :on-click on-delete-variant} + [:> icon* {:icon-id i/close + :size "s" + :aria-hidden true}]])])] (if ^boolean edition? [:div {:class (stl/css :table-field :options)} - [:button - {:disabled (str/blank? font-family) - :on-click on-save - :class (stl/css-case :btn-primary true - :btn-disabled (str/blank? font-family))} + [:> button* {:variant "primary" + :type "button" + :class (stl/css :upload-button) + :disabled (str/blank? font-family) + :on-click on-save} (tr "labels.save")] - [:button {:class (stl/css :icon :close) - :on-click on-cancel} - deprecated-icon/close]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.cancel") + :icon i/close + :on-click on-cancel}]] (when can-edit [:div {:class (stl/css :table-field :options)} - [:span {:class (stl/css :icon) - :on-click on-menu-open} - deprecated-icon/menu] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") + :aria-haspopup "menu" + :aria-expanded menu-open? + :icon i/menu + :on-click on-menu-open}] [:> installed-font-context-menu* {:on-close on-menu-close @@ -468,12 +490,16 @@ (cond (seq fonts) [:* - [:h3 (tr "labels.installed-fonts")] + [:> heading* {:level 3 + :typography t/title-small + :class (stl/css :installed-fonts-title)} + (tr "labels.installed-fonts")] [:div {:class (stl/css :installed-fonts-header)} [:div {:class (stl/css :table-field :family)} (tr "labels.font-family")] [:div {:class (stl/css :table-field :variants)} (tr "labels.font-variants")] [:div {:class (stl/css :table-field :search-input)} - [:input {:placeholder (tr "labels.search-font") + [:input {:class (stl/css :search-font-input) + :placeholder (tr "labels.search-font") :default-value "" :on-change on-change}]]] (for [[font-id variants] (->> (vals fonts) @@ -486,13 +512,16 @@ (nil? fonts) [:div {:class (stl/css :fonts-placeholder)} - [:div {:class (stl/css :icon)} deprecated-icon/loader] + [:> loader* {:width 32 + :title (tr "labels.loading")}] [:div {:class (stl/css :label)} (tr "dashboard.loading-fonts")]] :else (if ^boolean can-edit [:div {:class (stl/css :fonts-placeholder)} - [:div {:class (stl/css :icon)} deprecated-icon/text] + [:> icon* {:icon-id i/text + :size "xl" + :class (stl/css :placeholder-icon)}] [:div {:class (stl/css :label)} (tr "dashboard.fonts.empty-placeholder")]] [:> empty-placeholder* diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss index c8cbcdf808..e2e6430ede 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.scss +++ b/frontend/src/app/main/ui/dashboard/fonts.scss @@ -4,66 +4,60 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// The shared dashboard header styles move in their own change: they are +// used by ten dashboard modules. @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; -@use "ds/typography.scss" as t; +@use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; @use "ds/mixins.scss" as *; .dashboard-fonts { - border-top: $b-1 solid var(--color-background-quaternary); + border-block-start: $b-1 solid var(--color-background-quaternary); display: flex; flex-direction: column; - padding-left: px2rem(120); - padding-bottom: px2rem(120); + padding-inline-start: px2rem(120); + padding-block-end: px2rem(120); overflow-y: auto; - - .btn-primary { - font-size: px2rem(11); - height: $sz-32; - min-width: px2rem(100); - } } .dashboard-installed-fonts { - max-width: px2rem(1000); - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; display: flex; - margin-top: var(--sp-xxl); + margin-block-start: var(--sp-xxl); flex-direction: column; - h3 { - @include t.use-typography("title-small"); - - color: var(--color-foreground-secondary); - margin: var(--sp-xs); - } - .font-item { color: var(--color-background-secondary); } } +.installed-fonts-title { + color: var(--color-foreground-secondary); + margin: var(--sp-xs); +} + .installed-fonts-header { - @include t.use-typography("headline-small"); + @include use-typography("headline-small"); align-items: center; color: var(--color-foreground-secondary); display: flex; - height: $sz-40; - padding-left: var(--sp-xxl); + block-size: $sz-40; + padding-inline-start: var(--sp-xxl); > .family { @include two-line-text-ellipsis; - min-width: $sz-200; - width: $sz-200; + min-inline-size: $sz-200; + inline-size: $sz-200; } > .variants { - padding-left: var(--sp-m); + padding-inline-start: var(--sp-m); } } @@ -71,31 +65,31 @@ display: flex; flex-grow: 1; justify-content: flex-end; +} - input { - @include t.use-typography("body-medium"); +.search-font-input { + @include use-typography("body-medium"); - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border: $b-1 solid transparent; - color: var(--color-foreground-primary); - height: $sz-32; - margin: 0; - padding: 0 var(--sp-s); - width: px2rem(152); + background-color: var(--color-background-tertiary); + border-radius: $br-8; + border: $b-1 solid transparent; + color: var(--color-foreground-primary); + block-size: $sz-32; + margin: 0; + padding: 0 var(--sp-s); + inline-size: px2rem(152); - &:focus { - outline: $b-1 solid var(--color-accent-primary); - } + &:focus { + outline: $b-1 solid var(--color-accent-primary); + } - &::placeholder { - color: var(--color-foreground-secondary); - } + &::placeholder { + color: var(--color-foreground-secondary); } } .font-item { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); align-items: center; background-color: var(--color-background-tertiary); @@ -103,13 +97,13 @@ color: var(--color-foreground-secondary); display: flex; justify-content: space-between; - margin-top: var(--sp-xs); - max-width: px2rem(1000); + margin-block-start: var(--sp-xs); + max-inline-size: px2rem(1000); padding: var(--sp-m) var(--sp-xxl); - width: 100%; + inline-size: 100%; - input { - @include t.use-typography("body-medium"); + .name-input { + @include use-typography("body-medium"); @include text-ellipsis; border: $b-1 solid transparent; @@ -129,8 +123,8 @@ > .family { @include two-line-text-ellipsis; - min-width: $sz-200; - width: $sz-200; + min-inline-size: $sz-200; + inline-size: $sz-200; &.is-edition { overflow: visible; @@ -140,16 +134,16 @@ > .filenames { @include text-ellipsis; - min-width: $sz-200; + min-inline-size: $sz-200; } > .variants { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); display: flex; flex-wrap: wrap; flex-grow: 1; - padding-left: var(--sp-l); + padding-inline-start: var(--sp-l); gap: $sz-6; .variant { @@ -160,25 +154,11 @@ cursor: pointer; gap: var(--sp-xs); - .icon { - display: flex; - align-items: center; - justify-content: center; - height: $sz-16; - width: $sz-16; - - svg { - fill: none; - width: $sz-12; - height: $sz-12; - transform: rotate(45deg); - } - } + // The delete button only shows its icon while the variant is hovered. + --variant-delete-color: transparent; &:hover { - .icon svg { - stroke: var(--color-foreground-secondary); - } + --variant-delete-color: var(--color-foreground-secondary); } } @@ -197,79 +177,76 @@ } .filenames { - @include t.use-typography("body-small"); + @include use-typography("body-small"); @include text-ellipsis; - min-width: $sz-400; - padding-left: var(--sp-xxxl); + min-inline-size: $sz-400; + padding-inline-start: var(--sp-xxxl); } .options { display: flex; justify-content: flex-end; - min-width: px2rem(180); - - .icon { - width: $sz-24; - cursor: pointer; - display: flex; - margin-left: var(--sp-m); - justify-content: center; - align-items: center; - - svg { - width: $sz-16; - height: $sz-16; - stroke: var(--color-foreground-secondary); - fill: none; - } - - &.failure { - margin-right: var(--sp-m); - - svg { - stroke: var(--element-foreground-warning); - } - } - - &.close { - background: none; - border: none; - - svg { - stroke: var(--color-foreground-secondary); - } - } - } + min-inline-size: px2rem(180); + align-items: center; + gap: var(--sp-s); } } +.variant-delete { + display: flex; + align-items: center; + justify-content: center; + block-size: $sz-16; + inline-size: $sz-16; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--variant-delete-color); + + &:focus-visible { + --variant-delete-color: var(--color-foreground-secondary); + } +} + +// With the 8px gap of .options it keeps 12px on each side. +.warning-icon { + margin-inline: var(--sp-xs); + color: var(--color-accent-warning); +} + +// A DS button keeps its label at the start when it grows. +.upload-button { + min-inline-size: px2rem(100); + justify-content: center; +} + .dashboard-fonts-upload { - max-width: px2rem(1000); - width: 100%; + max-inline-size: px2rem(1000); + inline-size: 100%; display: flex; flex-direction: column; - .upload-button { - width: px2rem(100); - } - - .btn-secondary { - margin-left: var(--sp-m); + .dismiss-button { + margin-inline-start: var(--sp-xs); } } .dashboard-fonts-hero { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); padding: var(--sp-xxxl) 0; - margin-top: px2rem(80); + margin-block-start: px2rem(80); display: flex; justify-content: space-between; - .btn-primary { - height: $sz-40; - width: 100%; + .add-font-button { + --button-height: #{$sz-40}; + + inline-size: 100%; + flex-shrink: 0; + justify-content: center; } .desc { @@ -277,23 +254,18 @@ flex-direction: column; gap: var(--sp-xxl); color: var(--color-background-secondary); - width: $sz-500; + inline-size: $sz-500; - h2 { + .hero-title { color: var(--color-foreground-primary); - font-weight: 400; } - p { - @include t.use-typography("body-large"); + .hero-text { + @include use-typography("body-large"); color: var(--color-foreground-secondary); } } - - .btn-primary { - flex-shrink: 0; - } } .fonts-placeholder { @@ -302,21 +274,18 @@ border: $b-1 solid var(--color-background-quaternary); display: flex; flex-direction: column; - height: $sz-160; + block-size: $sz-160; justify-content: center; - margin-top: var(--sp-l); - max-width: px2rem(1000); - width: 100%; + margin-block-start: var(--sp-l); + max-inline-size: px2rem(1000); + inline-size: 100%; - .icon svg { - stroke: var(--color-foreground-secondary); - fill: none; - width: $sz-32; - height: $sz-32; + .placeholder-icon { + color: var(--color-foreground-secondary); } .label { - @include t.use-typography("body-medium"); + @include use-typography("body-medium"); color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 59708eef1a..4dc9408889 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -13,7 +13,6 @@ ;; Keep the list of icons sorted (def ^:icon icon-verify (icon-xref :icon-verify)) -(def ^:icon loader (icon-xref :loader)) (def ^:icon logo (icon-xref :penpot-logo)) (def ^:icon logo-icon (icon-xref :penpot-logo-icon)) (def ^:icon logo-error-screen (icon-xref :logo-error-screen)) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3fa9a763f3..09b305cc19 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -579,6 +579,10 @@ msgstr "Grid view" msgid "dashboard.files-layout.list" msgstr "List view" +#: src/app/main/ui/dashboard/fonts.cljs:431 +msgid "dashboard.fonts.delete-variant" +msgstr "Delete font style" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Missing font" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 431886f0d1..94ac75a0a6 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -587,6 +587,10 @@ msgstr "Vista de cuadrícula" msgid "dashboard.files-layout.list" msgstr "Vista de lista" +#: src/app/main/ui/dashboard/fonts.cljs:431 +msgid "dashboard.fonts.delete-variant" +msgstr "Eliminar estilo de fuente" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:608 msgid "dashboard.fonts.deleted-placeholder" msgstr "Fuente no encontrada" From e20a0d748eb9ae431c27a4a201ee9f7067d955e8 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Wed, 28 Oct 2026 18:40:04 +0100 Subject: [PATCH 187/325] :sparkles: Add OAuth support for MCP server (#12480) * :tada: Add OAuth support for remote MCP connections Add MCP-specific discovery, client registration, and approval endpoints. Exchange single-use authorization codes with PKCE for the fixed MCP user token, enabling MCP and creating the token on approval when needed. Use self-contained client IDs without new database tables or stored client records. Keep temporary authorization state in Redis/Valkey. Accept bearer credentials while preserving query-parameter connections. Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the environment variable in the README and implementation details in Serena. Explain protocol and concurrency decisions in the Clojure code. Cover token provisioning, OAuth state, client registration, HTTP flows, and the approval page with tests. AI-assisted-by: gpt-6 * :bug: Wait for devenv initialization before starting builds Docker reports a container as Running as soon as its main process starts, while the entrypoint may still be preparing the development environment. manage.sh used that status to launch tmux jobs, allowing frontend builds to race with ownership changes to /opt/rustup and Cargo setup. On a fresh container, rustup could fail with Permission denied before the frontend watcher started. Nginx would later serve the existing JavaScript, leaving source changes absent from the running app despite restarting devenv. Wait for nginx's local health endpoint before launching the jobs. Nginx starts after entrypoint setup, so its response confirms that setup has finished. Allow up to 120 seconds and report a timeout without starting the jobs if initialization does not complete. Validation: shell syntax and whitespace checks pass. AI-assisted-by: gpt-6 * :recycle: Share the login modal with MCP authorization Extract the viewer login modal and its styles into a shared auth component, retaining the viewer wrapper and its close behavior. Use the modal on the MCP approval page and preserve the pending request through login. Closing it clears the saved return URL and shows a cancellation message without further actions. Cover return URL handling and cancellation with frontend tests. AI-assisted-by: gpt-6 * :sparkles: Show client icons in MCP authorization Preserve HTTPS logo URLs from client registration and published metadata and show the icon beside the application name on the approval page. Ignore unusable URLs and hide failed images without blocking approval. Update the access description to refer to MCP connections established from Penpot. Cover logo metadata and approval responses in backend tests. AI-assisted-by: gpt-6 * :bug: Follow the system theme when no preference is configured Default to the system theme in the shared resolver used by the app and plugins. Preserve explicit light and dark choices, including the legacy default value that means dark. Reuse the reactive theme hook in progress widgets and show the system preference in settings for accounts without a configured theme. Verify preference resolution with regression tests and browser checks. All 1,474 frontend tests pass. AI-assisted-by: gpt-6 * :bug: Replace expired MCP tokens during OAuth approval Settings can give an MCP token an expiry date. OAuth approval reused the newest MCP token without checking it, so an expired token reached the client and never matched a plugin connection. Approval now treats an expired token as missing and creates a new one. Token exchange rejects a code whose token expired after approval with invalid_grant. AI-assisted-by: claude-opus-5-5 * :bug: Log unexpected errors on MCP OAuth endpoints The OAuth routes sit outside the shared HTTP middleware, so the shared error handler never saw them, and their own handler turned unexpected failures into a 503 without logging anything. Failures in the session middleware of the approval endpoints had no handler at all. Unexpected failures now also go through the shared error handler for its logging, while clients still get an OAuth error body. The OAuth error handler also wraps the session middleware of the approval endpoints. AI-assisted-by: claude-opus-5-5 * :wrench: Add devenv script to run the MCP server with OAuth start-mcp-devenv-oauth starts the MCP server in multi-user mode with PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in the devenv. Agentic tooling still uses the single-user start-mcp-devenv. AI-assisted-by: claude-opus-5-5 * :bug: Fix failing test case --------- Co-authored-by: alonso.torres --- .serena/memories/mcp/core.md | 27 +- .serena/memories/mcp/oauth.md | 47 +++ backend/src/app/http.clj | 7 +- backend/src/app/http/mcp_oauth.clj | 353 ++++++++++++++++++ backend/src/app/main.clj | 9 + backend/src/app/mcp/oauth_clients.clj | 220 +++++++++++ backend/src/app/mcp/oauth_rate.lua | 5 + backend/src/app/mcp/oauth_state.clj | 105 ++++++ backend/src/app/mcp/oauth_state.lua | 11 + backend/src/app/rpc/commands/access_token.clj | 27 ++ .../backend_tests/mcp_oauth_clients_test.clj | 219 +++++++++++ .../backend_tests/mcp_oauth_http_test.clj | 253 +++++++++++++ .../backend_tests/mcp_oauth_state_test.clj | 53 +++ .../backend_tests/rpc_access_tokens_test.clj | 38 ++ .../test/backend_tests/rpc_binfile_test.clj | 16 +- docker/devenv/files/nginx.conf | 5 + docker/images/files/nginx.conf.template | 5 + .../ui/specs/email-verification.spec.js | 37 +- .../playwright/ui/specs/workspace.spec.js | 2 + frontend/src/app/main/ui.cljs | 4 + frontend/src/app/main/ui/auth/login.cljs | 5 +- .../src/app/main/ui/auth/login_modal.cljs | 146 ++++++++ .../login.scss => auth/login_modal.scss} | 0 .../src/app/main/ui/components/progress.cljs | 8 +- frontend/src/app/main/ui/exports/assets.cljs | 8 +- frontend/src/app/main/ui/mcp_authorize.cljs | 162 ++++++++ frontend/src/app/main/ui/mcp_authorize.scss | 61 +++ frontend/src/app/main/ui/routes.cljs | 3 +- .../src/app/main/ui/settings/options.cljs | 2 +- frontend/src/app/main/ui/viewer/login.cljs | 131 +------ frontend/src/app/util/theme.cljs | 28 +- frontend/test/frontend_tests/runner.cljs | 4 + .../frontend_tests/ui/mcp_authorize_test.cljs | 88 +++++ .../test/frontend_tests/util_theme_test.cljs | 23 ++ frontend/translations/en.po | 24 ++ frontend/translations/es.po | 6 + manage.sh | 11 +- mcp/README.md | 1 + .../server/src/McpAuthentication.test.ts | 81 ++++ mcp/packages/server/src/McpAuthentication.ts | 120 ++++++ .../server/src/PenpotMcpServer.http.test.ts | 34 +- mcp/packages/server/src/PenpotMcpServer.ts | 15 +- mcp/scripts/start-mcp-devenv-oauth | 19 + 43 files changed, 2227 insertions(+), 196 deletions(-) create mode 100644 .serena/memories/mcp/oauth.md create mode 100644 backend/src/app/http/mcp_oauth.clj create mode 100644 backend/src/app/mcp/oauth_clients.clj create mode 100644 backend/src/app/mcp/oauth_rate.lua create mode 100644 backend/src/app/mcp/oauth_state.clj create mode 100644 backend/src/app/mcp/oauth_state.lua create mode 100644 backend/test/backend_tests/mcp_oauth_clients_test.clj create mode 100644 backend/test/backend_tests/mcp_oauth_http_test.clj create mode 100644 backend/test/backend_tests/mcp_oauth_state_test.clj create mode 100644 frontend/src/app/main/ui/auth/login_modal.cljs rename frontend/src/app/main/ui/{viewer/login.scss => auth/login_modal.scss} (100%) create mode 100644 frontend/src/app/main/ui/mcp_authorize.cljs create mode 100644 frontend/src/app/main/ui/mcp_authorize.scss create mode 100644 frontend/test/frontend_tests/ui/mcp_authorize_test.cljs create mode 100644 frontend/test/frontend_tests/util_theme_test.cljs create mode 100644 mcp/packages/server/src/McpAuthentication.test.ts create mode 100644 mcp/packages/server/src/McpAuthentication.ts create mode 100755 mcp/scripts/start-mcp-devenv-oauth diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 04bfef910e..551ef961c6 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -13,19 +13,26 @@ enabling advanced AI-driven features in Penpot. - Build Tool: TypeScript Compiler (tsc) + esbuild - Package Manager: pnpm -## General Principles +## Principles & Code Style IMPORTANT: Use an idiomatic, object-oriented style. -In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions -rather than mere functions (i.e. use the strategy pattern, for example). +* You keep each concern in exactly one home. + A mechanism whose parts are only correct in combination is implemented as a single component/class, and its parts are private to it; + expose a minimal public surface. Java-like encapsulation: If helper functions and constants are only used by one abstraction, + make them internal to the respective class. +* For any non-trivial interfaces, you use functions/methods that expect explicitly typed abstractions + rather than mere functions (i.e. use the strategy pattern, for example). +* Prefer declaring and instantiating concrete classes over defining only interfaces and using object literals. Comments: -When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase -clearly defines *what* it is. Any details then follow in subsequent sentences. +* When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase + clearly defines *what* it is. Any details then follow in subsequent sentences. +* When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless + the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is + required for sentences). -When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless -the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is -required for sentences). +Syntax: +* Never use if blocks (or other blocks) without braces, even for single-line cases. ## Project Structure (Excerpt) @@ -102,6 +109,10 @@ From the `mcp/` directory, run stdout directly. `NODE_TEST_CONTEXT` is the env var `node --test` sets. * Cross-cutting testing principles and anti-patterns: `mem:testing`. +## OAuth for remote MCP clients + +- Authorization/discovery URLs, self-contained client IDs, consent and PKCE/code binding, backend Redis state, deployment wiring, and compatibility limits: `mem:mcp/oauth`. + ## Devenv plugin/server wiring In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter. diff --git a/.serena/memories/mcp/oauth.md b/.serena/memories/mcp/oauth.md new file mode 100644 index 0000000000..097bdc1186 --- /dev/null +++ b/.serena/memories/mcp/oauth.md @@ -0,0 +1,47 @@ +# MCP OAuth + +## Contract and scope + +- MCP-specific authorization; existing Penpot login identifies the user on the approval page. No application vetting or persistent consent policy. Access requires user approval; registration alone grants nothing. +- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no unexpired one exists; preserve an existing unexpired token. Settings can give MCP tokens an expiry, and an expired one is replaced. No refresh token, `expires_in`, or new token lifetime. +- No new client database table or schema migration. Client IDs must remain usable across separate authorization flows without server-side registration storage. Do not replace them with expiring Redis registrations. +- MCP transport continues matching user tokens to connected Penpot plugins; no new per-request token introspection or independent semantic validation. The logged-in Penpot instance remains the content-access boundary. Do not silently add resource-server validation or change token semantics. +- README scope: user/developer usage instructions; document `PENPOT_MCP_AUTH_URI` in its environment-variable table. Keep OAuth architecture and protocol details here and non-obvious invariants in source comments. + +## Deployment and discovery + +- MCP process: `PENPOT_MCP_AUTH_URI=https://penpot.example/api/mcp/oauth` with `--multi-user`. Unset/empty disables OAuth; configured OAuth requires multi-user. Absolute HTTPS, or HTTP on loopback for development; no userinfo, query, or fragment. Invalid configuration fails startup; startup logs OAuth status. +- Devenv: `mcp/scripts/start-mcp-devenv-oauth` starts multi-user mode with `PENPOT_MCP_AUTH_URI` derived from `PENPOT_PUBLIC_URI`. Manual opt-in only: agentic tooling needs the single-user `start-mcp-devenv`. +- Public MCP transport: `/mcp/stream`, proxied to Node `/mcp`. Missing credentials with OAuth enabled: 401 plus `WWW-Authenticate` referencing `/resource` and scope `mcp`. +- Bearer header takes precedence over legacy `?userToken=`. Malformed headers do not fall back to the query token. Existing query-token clients remain supported. +- Protected-resource metadata: `/api/mcp/oauth/resource`; issuer: `/api/mcp/oauth`; authorization-server metadata: `/.well-known/oauth-authorization-server/api/mcp/oauth`. All reach the backend. Bundled devenv/production nginx configs forward the well-known path; custom proxies must do likewise. Keep Penpot public URI and external URLs consistent. +- Node implementation: `mcp/packages/server/src/McpAuthentication.ts`, integrated by `PenpotMcpServer.ts`; OAuth-enabled tools advertise `_meta.securitySchemes`. + +## Client identification + +- `backend/src/app/mcp/oauth_clients.clj`: dynamic registration at `/api/mcp/oauth/register`, HTTPS client metadata documents (CIMD), and registrations obtained in advance via `(app.mcp.oauth-clients/register! {} metadata)`. +- DCR returns `penpot-mcp-v1.` plus unpadded base64url JSON: normalized callback URLs, optional name, authentication method, random nonce, optional secret hash. Fixed grant/response/scope values are reconstructed. Nonce distinguishes registrations with otherwise identical metadata. +- IDs are public, unsigned, unencrypted, non-expiring metadata; validate on every resolution. Client secrets are generated randomly and returned separately; only the hash enters the ID. No signing/encryption key or client record in PostgreSQL/Redis. Old experimental database-backed UUID IDs require registration again. +- An edited payload changes the ID. Authorization-code exchange compares the exact ID string against the approved request before resolving metadata. Do not replace this with comparison of decoded fields; code ownership and PKCE binding must survive metadata edits. +- Supported authentication: `none`, `client_secret_basic`, `client_secret_post`; no `private_key_jwt`. CIMD selects public-client `none` from the document's supported methods; other listed methods do not imply support here. Scope `mcp`; grant `authorization_code`; response `code`. CIMD may advertise additional grants (e.g. Codex lists `refresh_token`); require `authorization_code` support and normalize to the grant Penpot implements, rather than reject broader client capabilities. +- Entire ID capped at 2,048 ASCII characters. DCR returns `invalid_client_metadata` if encoded metadata exceeds this; shorter/fewer callback URLs reduce size. OAuth defines no fixed ID-length maximum (RFC 6749 §2.2); stateless IDs are explicitly described by RFC 7591 §A.5.2. Installed MCP client SDK 2.3.1 accepts a 2,048-character ID; this does not establish Codex/Claude compatibility. +- Redirect matching is exact except loopback HTTP port changes for native clients (`127.0.0.1`, `[::1]`, exact `localhost`). Host, path and query must still match; reject fragments/userinfo and lookalike localhost domains. Allow HTTPS, loopback HTTP, and reverse-domain native schemes. +- CIMD: fetch with existing SSRF-checked `app.http.client/req`, no redirect following, require matching document `client_id`, bounded 64-KiB body and request/body-read timeouts. Cache max 256 entries/one minute, further limited by HTTP max-age; no-store/no-cache/no max-age means no reuse. + +## Approval and exchange + +- `backend/src/app/http/mcp_oauth.clj`: separate router branch from ordinary RPC middleware; preserve OAuth snake_case JSON and form encoding. Session auth applies to approval request/decision only. Public metadata/register/token endpoints allow noncredentialed CORS; MCP transport CORS was not added. +- Errors: every failure answers with an OAuth `{"error": ...}` body, never the shared Penpot error shape. Failures without `:oauth-error` are also passed to `app.http.errors/handle` for logging only; its response is discarded. `wrap-errors` also wraps the session middleware of `/request` and `/decision`. Safe to log because these routes never set request `:params`. +- `/authorize`: validate client/callback, `code`, PKCE S256, scope/resource/state; store pending request for 600 seconds; redirect to `?screen=mcp-authorize&request=`. +- `frontend/src/app/main/ui/mcp_authorize.cljs`: existing login with return URL; show application and account details; submit explicit Allow/Cancel. No fixed MCP token in frontend state or browser redirect. Approval refreshes profile and broadcasts MCP enablement; does not connect a plugin automatically. +- `/request`: first authenticated read binds request to profile plus hashed login-session ID and CSRF value, using compare-and-replace. Re-read after the attempt: concurrent GETs may win/lose binding; only the matching session may proceed. +- `/decision`: check exact Origin, session binding and CSRF, then atomically consume the request. Denial creates no token/enables nothing. Approval calls transactional `access-token/ensure-mcp-access!`, then stores code for 120 seconds. Code state holds token row ID, never the fixed token. Callback echoes OAuth `state` and RFC 9207 `iss`. +- `/token`: validate grant, exact client ID/callback/resource and PKCE; resolve current client metadata/authentication method and verify any secret; atomically consume validated code; fetch the unexpired token by original row ID/profile/type. Regeneration deletes the old token row, so a pending code cannot silently yield the replacement token. Return fixed `access_token`, `token_type: Bearer`, scope `mcp`. +- `backend/src/app/mcp/oauth_state.clj` and Lua: tenant/kind/hash-prefixed keys; random handles; SET NX EX must succeed before returning handle. Reads retain raw JSON for atomic comparison. Consume/delete and bind/replace compare the validated raw record in Lua; replacement preserves TTL. Redis failures fail closed. Backend Redis is required independently of Node's optional Redis plugin bridge. +- `backend/src/app/rpc/commands/access_token.clj`: lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. + +## Verification and limits + +- Tests: `mcp_oauth_clients_test.clj`, `mcp_oauth_http_test.clj`, `mcp_oauth_state_test.clj`, OAuth provisioning in `rpc_access_tokens_test.clj`; MCP authentication/HTTP tests; frontend approval tests. Cover no-storage registration, size boundaries, edited-ID rejection, code replay/concurrency, session/CSRF binding, and fixed-token reuse. +- Live OpenAI/Codex/Claude compatibility and marketplace acceptance remain unverified. In particular, verify token persistence without `expires_in` and acceptance of the existing matching-only resource boundary; do not claim protocol conformance from SDK tests alone. +- Protocol references: https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2 ; https://www.rfc-editor.org/rfc/rfc7591.html#appendix-A.5.2 ; https://www.rfc-editor.org/rfc/rfc7636.html ; https://www.rfc-editor.org/rfc/rfc9207.html . diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index aa6950fdeb..83af60c3d2 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -19,6 +19,7 @@ [app.http.errors :as errors] [app.http.link-preview :as-alias link-preview] [app.http.management :as mgmt] + [app.http.mcp-oauth :as-alias mcp-oauth] [app.http.middleware :as mw] [app.http.security :as sec] [app.http.session :as session] @@ -236,6 +237,7 @@ [::ws/routes schema:routes] [::rpc/routes schema:routes] [::oidc/routes schema:routes] + [::mcp-oauth/routes schema:routes] [::assets/routes schema:routes] [::link-preview/routes schema:routes] [::debug/routes schema:routes] @@ -278,4 +280,7 @@ (::ws/routes cfg) (::oidc/routes cfg) - (::rpc/routes cfg)]])) + (::rpc/routes cfg)] + ;; OAuth owns form parsing, protocol JSON names and cross-origin handling; + ;; it must not inherit the RPC transformations and session middleware above. + (::mcp-oauth/routes cfg)])) diff --git a/backend/src/app/http/mcp_oauth.clj b/backend/src/app/http/mcp_oauth.clj new file mode 100644 index 0000000000..5184cc7e65 --- /dev/null +++ b/backend/src/app/http/mcp_oauth.clj @@ -0,0 +1,353 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.http.mcp-oauth + "MCP-specific OAuth authorization server. + + The authorize endpoint stages a request; the existing Penpot session identifies + the user on the approval page. Approval creates a one-use code, and token + exchange returns the existing fixed MCP user token after verifying PKCE. + Client registration, login sessions, and authorization codes are distinct." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.errors :as errors] + [app.http.middleware :as mw] + [app.http.session :as session] + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.rpc.commands.access-token :as access-token] + [app.util.json :as json] + [clojure.string :as str] + [integrant.core :as ig] + [yetti.request :as yreq] + [yetti.response :as yres]) + (:import + java.io.InputStream + java.net.URI + java.net.URLDecoder + java.net.URLEncoder + java.nio.charset.StandardCharsets + java.util.Base64)) + +(defn issuer [] (str (cf/get-public-uri "api/mcp/oauth"))) +(defn resource [] (str (cf/get-public-uri "mcp/stream"))) + +(defn authorization-metadata + "OAuth capabilities and endpoints advertised to MCP clients." + [] + {:issuer (issuer) + :authorization_endpoint (str (issuer) "/authorize") + :token_endpoint (str (issuer) "/token") + :registration_endpoint (str (issuer) "/register") + :response_types_supported ["code"] + :grant_types_supported ["authorization_code"] + :code_challenge_methods_supported ["S256"] + :token_endpoint_auth_methods_supported ["none" "client_secret_basic" "client_secret_post"] + :scopes_supported ["mcp"] + :authorization_response_iss_parameter_supported true + :client_id_metadata_document_supported true}) + +(defn resource-metadata + "Protected-resource metadata referenced by the MCP server's 401 challenge." + [] + {:resource (resource) + :authorization_servers [(issuer)] + :scopes_supported ["mcp"] + :bearer_methods_supported ["header"] + :resource_name "Penpot MCP"}) + +(defn- response + ([body] (response 200 body)) + ([status body] + {::yres/status status + ::yres/headers {"content-type" "application/json" + "cache-control" "no-store" + "pragma" "no-cache" + "referrer-policy" "no-referrer"} + ::yres/body (json/encode-str body)})) + +(defn- redirect + [url] + {::yres/status 303 + ::yres/headers {"location" url "cache-control" "no-store" + "referrer-policy" "no-referrer"} + ::yres/body ""}) + +(defn- encode [^String value] (URLEncoder/encode value StandardCharsets/UTF_8)) +(defn- decode + [^String value] + (try (URLDecoder/decode value StandardCharsets/UTF_8) + (catch IllegalArgumentException _ (clients/invalid! "invalid_request")))) + +(defn- parse-form + "Bounded OAuth form/query parser preserving protocol parameter names. + Rejects duplicate keys so validation never depends on which value wins." + [query] + (when (> (count query) 16384) (clients/invalid! "invalid_request")) + (reduce (fn [result part] + (let [[key value] (str/split part #"=" 2) + key (keyword (decode key))] + (when (contains? result key) (clients/invalid! "invalid_request")) + (assoc result key (decode (or value ""))))) + {} + (remove str/blank? (str/split (or query "") #"&")))) + +(defn- read-body + [request content-type] + (when-not (str/starts-with? (or (yreq/get-header request "content-type") "") content-type) + (clients/invalid! "invalid_request")) + (with-open [^InputStream body (yreq/body request)] + (when-not body (clients/invalid! "invalid_request")) + (let [bytes (.readNBytes body 16385)] + (when (> (alength bytes) 16384) (clients/invalid! "invalid_request")) + (String. bytes StandardCharsets/UTF_8)))) + +(defn- append-query + [url params] + (str url (if (str/includes? url "?") "&" "?") + (str/join "&" (for [[key value] params :when (some? value)] + (str (encode (name key)) "=" (encode value)))))) + +(defn- limit! + [cfg request kind limit] + (when-not (state/allow-request! cfg kind (str (yreq/remote-addr request)) limit) + (clients/invalid! "temporarily_unavailable" 429))) + +(defn- authorize + "Validate and stage an authorization request before user login or consent. + The browser receives only an opaque handle to the ten-minute Redis record." + [cfg request] + (limit! cfg request :authorize-rate 60) + (let [params (parse-form (yreq/query request)) + client (clients/resolve-client cfg (:client_id params)) + callback (:redirect_uri params)] + ;; Never redirect until the client and its callback have been checked. + (when-not (clients/redirect-matches? client callback) + (clients/invalid! "invalid_request")) + (when-not (and (= "code" (:response_type params)) + (= "S256" (:code_challenge_method params)) + (string? (:code_challenge params)) + (re-matches #"[A-Za-z0-9_-]{43}" (:code_challenge params)) + (= "mcp" (get params :scope "mcp")) + (= (resource) (get params :resource (resource))) + (<= (count (:state params)) 2048)) + (clients/invalid! "invalid_request")) + (let [handle (state/create! cfg :request + {:client-id (:client_id client) + :client-name (:client_name client) + :logo-uri (:logo_uri client) + :redirect-uri callback + :challenge (:code_challenge params) + :state (:state params) + :resource (resource) + :scope "mcp"} + 600)] + (redirect (append-query (str (cf/get-public-uri)) + {:screen "mcp-authorize" :request handle}))))) + +(defn- session-binding + "Profile and login-session identity used to bind one approval request." + [request] + (let [session (session/get-session request)] + (when-not (:profile-id session) (clients/invalid! "login_required" 401)) + {:profile-id (str (:profile-id session)) + :session-id (state/digest (str (:id session)))})) + +(defn- approval-request + "Bind a pending request to its first authenticated session and return UI data. + Compare-and-replace prevents another session from taking over that request." + [cfg request] + (let [binding (session-binding request) + handle (:request (parse-form (yreq/query request))) + record (state/read! cfg :request handle)] + (when-not record (clients/invalid! "invalid_request")) + (let [value (:value record)] + (if (:session-id value) + (when-not (= binding (select-keys value [:profile-id :session-id])) + (clients/invalid! "access_denied" 403)) + (state/replace! cfg :request handle record + (merge value binding {:csrf (state/random-secret)})))) + ;; Another GET may have won the binding race. Read the winning record so + ;; same-session retries share its CSRF value; other sessions cannot proceed. + (let [value (:value (state/read! cfg :request handle))] + (when-not (= binding (select-keys value [:profile-id :session-id])) + (clients/invalid! "invalid_request")) + (response (select-keys value [:client-id :client-name :logo-uri :csrf :scope]))))) + +(defn- same-origin? + [request] + (let [public (URI. (str (cf/get-public-uri))) + origin (str (.getScheme public) "://" (.getRawAuthority public))] + (= origin (yreq/get-header request "origin")))) + +(defn- decision + "Accept one session-bound approval/denial and return the client callback. + Origin and CSRF checks protect the cookie-authenticated decision endpoint." + [cfg request] + (when-not (same-origin? request) (clients/invalid! "access_denied" 403)) + (let [binding (session-binding request) + params (parse-form (read-body request "application/x-www-form-urlencoded")) + handle (:request params) + record (state/read! cfg :request handle) + value (:value record)] + (when-not (and record + (= binding (select-keys value [:profile-id :session-id])) + (state/secret= (:csrf value) (:csrf params)) + (contains? #{"approve" "deny"} (:decision params))) + (clients/invalid! "invalid_request")) + ;; Consume before provisioning: concurrent clicks must not issue two codes. + ;; A later failure requires a fresh authorization attempt. + (when-not (state/consume! cfg :request handle record) + (clients/invalid! "invalid_request")) + (let [result (if (= "deny" (:decision params)) + {:error "access_denied"} + (let [token (db/tx-run! cfg access-token/ensure-mcp-access! + (uuid/parse (:profile-id value))) + ;; Retain the token row ID, never the fixed token itself. + code (state/create! cfg :code + (-> value + (dissoc :csrf :session-id :client-name) + (assoc :token-id (str (:id token)))) + 120)] + {:code code}))] + (response {:redirect-uri (append-query (:redirect-uri value) + (merge result {:state (:state value) + :iss (issuer)}))})))) + +(defn- client-authentication + "Extract one OAuth client-authentication method, rejecting mixed credentials. + Basic credentials are form-decoded after splitting the decoded header." + [request params] + (let [header (yreq/get-header request "authorization")] + (if header + (do + (when (or (:client_secret params) (not (str/starts-with? header "Basic "))) + (clients/invalid! "invalid_client" 401)) + (let [decoded (try (String. (.decode (Base64/getDecoder) (subs header 6)) + StandardCharsets/UTF_8) + (catch IllegalArgumentException _ + (clients/invalid! "invalid_client" 401))) + [id secret] (str/split decoded #":" 2) + id (decode id)] + (when (or (nil? secret) + (and (:client_id params) (not= id (:client_id params)))) + (clients/invalid! "invalid_client" 401)) + {:id id :secret (decode secret) :method "client_secret_basic"})) + {:id (:client_id params) :secret (:client_secret params) + :method (if (:client_secret params) "client_secret_post" "none")}))) + +(defn- token + "Exchange a code for the fixed MCP token after client and PKCE verification." + [cfg request] + (limit! cfg request :token-rate 120) + (let [params (parse-form (read-body request "application/x-www-form-urlencoded")) + auth (client-authentication request params) + record (state/read! cfg :code (:code params)) + value (:value record)] + (when-not (= "authorization_code" (:grant_type params)) + (clients/invalid! "unsupported_grant_type")) + ;; Compare the exact client ID, not decoded metadata: a self-contained ID + ;; edited after approval identifies a different client for this exchange. + (when-not (and record + (= (:id auth) (:client-id value)) + (= (:redirect_uri params) (:redirect-uri value)) + (= (:resource value) (get params :resource (:resource value))) + (string? (:code_verifier params)) + (re-matches #"[A-Za-z0-9._~-]{43,128}" (:code_verifier params)) + (state/secret= (:challenge value) (state/digest (:code_verifier params)))) + (clients/invalid! "invalid_grant")) + ;; Resolve again because an HTTPS metadata document may have changed + ;; since approval; its current callback and authentication method apply. + (let [client (clients/resolve-client cfg (:id auth))] + (when-not (and (= (:method auth) (:token_endpoint_auth_method client)) + (clients/redirect-matches? client (:redirect-uri value)) + (or (= "none" (:method auth)) + (and (string? (:secret auth)) + (state/secret= (:secret-hash client) + (state/digest (:secret auth)))))) + (clients/invalid! "invalid_client" 401))) + ;; Validate before consuming so an invalid verifier/secret cannot burn the + ;; code. Atomic consumption allows only one valid exchange to succeed. + (when-not (state/consume! cfg :code (:code params) record) + (clients/invalid! "invalid_grant")) + ;; Regeneration deletes the old token row. Never substitute the profile's + ;; latest token for the one this approval authorized, nor return it expired. + (let [row (db/exec-one! (::db/pool cfg) + [(str "SELECT token FROM access_token" + " WHERE id = ? AND profile_id = ? AND type = 'mcp'" + " AND (expires_at IS NULL OR expires_at > now())") + (uuid/parse (:token-id value)) + (uuid/parse (:profile-id value))])] + (when-not row (clients/invalid! "invalid_grant")) + (response {:access_token (:token row) :token_type "Bearer" :scope "mcp"})))) + +(defn- register + "Issue a self-contained client registration; does not grant user access." + [cfg request] + (limit! cfg request :registration-rate 10) + (let [body (read-body request "application/json") + metadata (try (json/decode body) + (catch Exception _ (clients/invalid! "invalid_request")))] + (response 201 (clients/register! cfg metadata)))) + +(defn- wrap-errors + "Answer every failure with an OAuth error body. Unexpected failures also go + through the shared HTTP error handler, only for its logging; these routes + never set request params, so codes and credentials stay out of the log." + [handler] + (fn [request] + (try (handler request) + (catch Exception cause + (let [{:keys [oauth-error status]} (ex-data cause) + status (or status (if (and oauth-error (not= oauth-error "temporarily_unavailable")) 400 503))] + (when-not oauth-error + (errors/handle cause request)) + (cond-> (response status {:error (or oauth-error "temporarily_unavailable")}) + (and (= status 401) (= oauth-error "invalid_client")) + (assoc-in [::yres/headers "www-authenticate"] "Basic realm=\"Penpot MCP\""))))))) + +(defn- wrap-public + "CORS for discovery, registration and exchange without browser credentials. + Cookie-authenticated approval endpoints must not use this wrapper." + [handler] + (fn [request] + (let [response (if (= :options (yreq/method request)) + {::yres/status 204} + (handler request))] + (update response ::yres/headers merge + {"access-control-allow-origin" "*" + "access-control-allow-methods" "GET, POST, OPTIONS" + "access-control-allow-headers" "Content-Type, Authorization" + "access-control-expose-headers" "WWW-Authenticate"})))) + +(defn- endpoint + [cfg handler method public?] + {:handler (partial handler cfg) + :middleware (into (if public? [{:compile (constantly wrap-public)}] []) + [{:compile (constantly wrap-errors)} [mw/restrict-methods]]) + :allowed-methods (cond-> #{method} public? (conj :options))}) + +;; OAuth endpoints parse forms and emit snake_case JSON independently of RPC +;; middleware. Only the approval UI endpoints require a Penpot login session. +(defmethod ig/init-key ::routes + [_ cfg] + ["" + ["/.well-known/oauth-authorization-server/api/mcp/oauth" + (endpoint cfg (fn [_ _] (response (authorization-metadata))) :get true)] + ["/api/mcp/oauth" + ["/resource" (endpoint cfg (fn [_ _] (response (resource-metadata))) :get true)] + ["/authorize" (endpoint cfg authorize :get false)] + ["/register" (endpoint cfg register :post true)] + ["/token" (endpoint cfg token :post true)] + ;; Error handling wraps the session middleware too, so its failures also + ;; get OAuth error bodies and logging. + ["" {:middleware [{:compile (constantly wrap-errors)} + [mw/auth {:cookie (partial session/decode-token cfg)}] + [session/authz cfg]]} + ["/request" (endpoint cfg approval-request :get false)] + ["/decision" (endpoint cfg decision :post false)]]]]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index f8d856fb76..9267c889f4 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -22,6 +22,7 @@ [app.http.debug :as-alias http.debug] [app.http.link-preview :as-alias http.link-preview] [app.http.management :as mgmt] + [app.http.mcp-oauth :as-alias http.mcp-oauth] [app.http.session :as session] [app.http.websocket :as http.ws] [app.jobs :as-alias jobs] @@ -540,9 +541,17 @@ ::http.debug/routes (ig/ref ::http.debug/routes) ::http.assets/routes (ig/ref ::http.assets/routes) ::http.link-preview/routes (ig/ref ::http.link-preview/routes) + ::http.mcp-oauth/routes (ig/ref ::http.mcp-oauth/routes) ::http.ws/routes (ig/ref ::http.ws/routes) ::http.awsns/routes (ig/ref ::http.awsns/routes)} + ::http.mcp-oauth/routes + {::db/pool (ig/ref ::db/pool) + ::rds/pool (ig/ref ::rds/pool) + ::setup/props (ig/ref ::setup/props) + ::session/manager (ig/ref ::session/manager) + ::http.client/client (ig/ref ::http.client/client)} + ::http.link-preview/routes {::db/pool (ig/ref ::db/pool)} diff --git a/backend/src/app/mcp/oauth_clients.clj b/backend/src/app/mcp/oauth_clients.clj new file mode 100644 index 0000000000..2e36fa6751 --- /dev/null +++ b/backend/src/app/mcp/oauth_clients.clj @@ -0,0 +1,220 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.mcp.oauth-clients + "Client metadata from self-contained registrations or HTTPS documents. + Registration describes an application; it does not authorize a Penpot user. + Both sources resolve to the same metadata shape without a client database." + (:require + [app.http.client :as http] + [app.mcp.oauth-state :as state] + [app.util.cache :as cache] + [app.util.json :as json] + [clojure.string :as str]) + (:import + java.io.InputStream + java.net.URI + java.util.Base64)) + +(defn invalid! + ([error] (invalid! error 400)) + ([error status] + (throw (ex-info "MCP OAuth request rejected" {:oauth-error error :status status})))) + +(defn uri + [value] + (when (and (string? value) (<= 1 (count value) 2048)) + (try (URI. value) (catch Exception _ nil)))) + +(defn- loopback? + [^URI uri] + (and (= "http" (.getScheme uri)) + (contains? #{"127.0.0.1" "[::1]" "localhost"} (.getHost uri)))) + +(defn redirect-uri? + [value] + (when-let [uri (uri value)] + (and (nil? (.getFragment uri)) + (nil? (.getUserInfo uri)) + (or (and (= "https" (.getScheme uri)) (some? (.getHost uri))) + (loopback? uri) + ;; Native applications use a reverse-domain scheme. + (and (some? (.getScheme uri)) + (str/includes? (.getScheme uri) ".") + (not (.isOpaque uri))))))) + +(defn redirect-matches? + "Exact callback match, except for the port of loopback HTTP URLs. + Native applications may allocate a different local port for each login." + [registered requested] + (and (redirect-uri? requested) + (some (fn [value] + (or (= value requested) + (let [a (uri value) + b (uri requested)] + (and (loopback? a) (loopback? b) + (= (.getHost a) (.getHost b)) + (= (.getRawPath a) (.getRawPath b)) + (= (.getRawQuery a) (.getRawQuery b)))))) + (:redirect_uris registered)))) + +(defn- logo-uri? + [value] + (when-let [uri (uri value)] + (and (= "https" (.getScheme uri)) + (some? (.getHost uri)) + (nil? (.getUserInfo uri)) + (nil? (.getFragment uri))))) + +(defn validate-metadata + "Validate untrusted metadata and retain the fields this server supports. + CIMD selects public-client authentication from the advertised methods; + dynamic registration also supports Basic and form-post client secrets." + [metadata cimd?] + (let [redirects (:redirect_uris metadata) + grants (get metadata :grant_types ["authorization_code"]) + methods (if cimd? + (or (:token_endpoint_auth_methods_supported metadata) + [(get metadata :token_endpoint_auth_method "none")]) + [(get metadata :token_endpoint_auth_method "client_secret_basic")]) + method (if cimd? "none" (first methods))] + (when-not (and (map? metadata) + (vector? redirects) (<= 1 (count redirects) 20) + (every? redirect-uri? redirects) + (or (nil? (:client_name metadata)) + (and (string? (:client_name metadata)) + (<= 1 (count (:client_name metadata)) 200))) + ;; Published metadata describes client capabilities across + ;; servers. Extra grants do not require us to issue them. + (if cimd? + (and (vector? grants) (every? string? grants) + (some #{"authorization_code"} grants)) + (= ["authorization_code"] grants)) + (= ["code"] (get metadata :response_types ["code"])) + (or (nil? (:scope metadata)) (= "mcp" (:scope metadata))) + (if cimd? + (and (vector? methods) (some #{"none"} methods)) + (contains? #{"none" "client_secret_basic" "client_secret_post"} method))) + (invalid! "invalid_client_metadata")) + (cond-> {:redirect_uris redirects + :grant_types ["authorization_code"] + :response_types ["code"] + :scope "mcp" + :token_endpoint_auth_method method} + (:client_name metadata) (assoc :client_name (:client_name metadata)) + ;; Logos are optional browser-loaded images. Ignore unusable URLs + ;; instead of preventing authorization because of cosmetic metadata. + (logo-uri? (:logo_uri metadata)) (assoc :logo_uri (:logo_uri metadata))))) + +(defn register! + "Issue a reusable client ID and, when applicable, a separate client secret. + No client record is stored. The public ID carries only the secret hash; + the random nonce distinguishes registrations with otherwise equal metadata." + [_cfg metadata] + (let [metadata (validate-metadata metadata false) + secret (when-not (= "none" (:token_endpoint_auth_method metadata)) + (state/random-secret)) + ;; Omit fixed protocol values to keep IDs short; validation restores + ;; them on resolution. The version prefix defines the payload format. + payload (cond-> (-> metadata + (dissoc :grant_types :response_types :scope) + (assoc :nonce (state/random-secret))) + secret (assoc :secret-hash (state/digest secret))) + id (str "penpot-mcp-v1." + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) + (json/encode payload)))] + ;; Enforce the resolver's size cap here rather than issue an unusable ID. + (when (> (count id) 2048) + (invalid! "invalid_client_metadata")) + (cond-> (assoc metadata :client_id id + :client_id_issued_at (quot (System/currentTimeMillis) 1000)) + secret (assoc :client_secret secret :client_secret_expires_at 0)))) + +(defn- decode-client-id + [client-id] + ;; Open registration accepts client-supplied metadata, not a trusted identity. + ;; Changing these details changes the ID; codes are bound to the exact ID + ;; approved by the user, so an edited ID cannot redeem an existing code. + (try + (let [[_ encoded] (re-matches #"penpot-mcp-v1\.([A-Za-z0-9_-]+)" client-id) + _ (when-not encoded (invalid! "invalid_client")) + payload (json/decode (.decode (Base64/getUrlDecoder) ^String encoded)) + metadata (validate-metadata payload false) + nonce (:nonce payload) + secret-hash (:secret-hash payload)] + (when-not (and (string? nonce) (re-matches #"[A-Za-z0-9_-]{43}" nonce) + (if (= "none" (:token_endpoint_auth_method metadata)) + (nil? secret-hash) + (and (string? secret-hash) + (re-matches #"[A-Za-z0-9_-]{43}" secret-hash)))) + (invalid! "invalid_client")) + (cond-> (assoc metadata :client_id client-id) + secret-hash (assoc :secret-hash secret-hash))) + (catch Exception _ + (invalid! "invalid_client")))) + +(defonce ^:private metadata-cache + (cache/create :expire "1m" :max-size 256)) + +(defn- fetch-metadata + "Fetch a client's own HTTPS metadata document with bounded size and freshness. + The shared HTTP client checks SSRF targets and does not follow redirects." + [cfg client-id] + (let [response (http/req cfg {:method :get :uri client-id :timeout 5000 + :headers {"accept" "application/json"}} + {:response-type :input-stream})] + (with-open [^InputStream body (:body response)] + (when-not (and (= 200 (:status response)) + (str/starts-with? (get-in response [:headers "content-type"] "") + "application/json")) + (invalid! "invalid_client")) + ;; Bound both bytes read and time spent receiving the response body. + (let [reader (future (.readNBytes body 65537)) + bytes (deref reader 5000 ::timeout)] + (when (= ::timeout bytes) + (future-cancel reader) + (invalid! "invalid_client")) + (when (> (alength ^bytes bytes) 65536) + (invalid! "invalid_client")) + (let [metadata (json/decode bytes) + control (get-in response [:headers "cache-control"] "") + ;; The local cache ceiling never extends the publisher's TTL. + ;; Missing cache lifetime is treated as immediately stale. + max-age (some-> (re-find #"(?i)max-age=(\d+)" control) second parse-long) + ttl (if (re-find #"(?i)no-store|no-cache" control) + 0 + (min 60 (or max-age 0)))] + (when-not (= client-id (:client_id metadata)) + (invalid! "invalid_client")) + {:client (assoc (validate-metadata metadata true) :client_id client-id) + :expires (+ (System/currentTimeMillis) (* ttl 1000))}))))) + +(defn resolve-client + "Resolve a client ID to validated metadata for authorization or exchange. + HTTPS IDs use the document cache; versioned IDs decode locally without + PostgreSQL or Redis. Neither form establishes user approval." + [cfg client-id] + (when-not (and (string? client-id) (<= 1 (count client-id) 2048)) + (invalid! "invalid_client")) + (if-let [url (when (str/starts-with? client-id "https://") (uri client-id))] + (do + (when-not (and (.getHost url) + (nil? (.getUserInfo url)) + (nil? (.getFragment url)) + (not (str/blank? (.getPath url)))) + (invalid! "invalid_client")) + (try + (let [cached (cache/get metadata-cache client-id) + entry (if (and cached (> (:expires cached) (System/currentTimeMillis))) + cached + (fetch-metadata cfg client-id))] + (when-not (identical? cached entry) + (cache/invalidate metadata-cache client-id) + (cache/get metadata-cache client-id (constantly entry))) + (:client entry)) + (catch Exception _ + (invalid! "invalid_client")))) + (decode-client-id client-id))) diff --git a/backend/src/app/mcp/oauth_rate.lua b/backend/src/app/mcp/oauth_rate.lua new file mode 100644 index 0000000000..0a8a168cfc --- /dev/null +++ b/backend/src/app/mcp/oauth_rate.lua @@ -0,0 +1,5 @@ +-- Copyright (c) KALEIDOS SUBSIDIARY SL +-- MPL-2.0 +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end +return {count} diff --git a/backend/src/app/mcp/oauth_state.clj b/backend/src/app/mcp/oauth_state.clj new file mode 100644 index 0000000000..6f27a3b05e --- /dev/null +++ b/backend/src/app/mcp/oauth_state.clj @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.mcp.oauth-state + "Temporary approval requests and authorization codes in Redis/Valkey. + Handles are opaque; tenant, record kind and handle hash form the key. + Mutations compare the exact record that the HTTP handler validated." + (:require + [app.config :as cf] + [app.redis :as rds] + [app.redis.script :as-alias script] + [app.util.json :as json]) + (:import + java.nio.charset.StandardCharsets + java.security.MessageDigest + java.security.SecureRandom + java.util.Base64)) + +(defn random-secret + "32 random bytes encoded as a 43-character, unpadded base64url string." + [] + (let [bytes (byte-array 32)] + (.nextBytes (SecureRandom.) bytes) + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bytes))) + +(defn digest + "Unpadded base64url SHA-256 digest, also used for PKCE S256 challenges." + [^String value] + (-> (.digest (MessageDigest/getInstance "SHA-256") + (.getBytes value StandardCharsets/UTF_8)) + (#(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) %)))) + +(defn secret= + [a b] + (and (string? a) (string? b) + (MessageDigest/isEqual (.getBytes ^String a StandardCharsets/UTF_8) + (.getBytes ^String b StandardCharsets/UTF_8)))) + +(defn- state-key + [kind handle] + (str "penpot:" (cf/get :tenant) ":mcp-oauth:" (name kind) ":" (digest handle))) + +(defn create! + "Store a new record for ttl seconds and return its random handle. + The handle is returned only after Redis confirms SET NX EX succeeded." + [cfg kind value ttl] + (let [handle (random-secret) + result (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/set conn (state-key kind handle) + (json/encode-str value) {:ex ttl :nx true})))] + (when-not (= "OK" result) + (throw (ex-info "Unable to store OAuth state" {:oauth-error "temporarily_unavailable"}))) + handle)) + +(defn read! + "Read an unexpired record, or nil for a missing/malformed handle. + Return both decoded :value for validation and original :raw for atomic + comparison; serializing the decoded map again is not an equivalent check." + [cfg kind handle] + (when (and (string? handle) (re-matches #"[A-Za-z0-9_-]{43}" handle)) + (when-let [raw (rds/run! cfg (fn [{::rds/keys [conn]}] + (rds/get conn (state-key kind handle))))] + {:raw raw :value (json/decode raw)}))) + +(defn- compare-and-update! + "Atomically change only the exact record the caller read and validated. + Lua deletes for nil replacement, or replaces while preserving the TTL. + False means the record expired or another request changed/consumed it." + [cfg kind handle record replacement] + (let [result (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/eval conn + {::script/name ::compare-and-update + ::script/path "app/mcp/oauth_state.lua" + ::script/keys [(state-key kind handle)] + ::script/vals [(:raw record) (or replacement "")]})))] + (= [1] result))) + +(defn consume! + "Delete a validated record once; true only for the request that consumes it." + [cfg kind handle record] + (compare-and-update! cfg kind handle record nil)) + +(defn replace! + "Replace a validated record without extending its original expiration." + [cfg kind handle record value] + (compare-and-update! cfg kind handle record (json/encode-str value))) + +(defn allow-request! + "Limit requests per endpoint kind, tenant and remote address. + The one-minute window begins with the first request; subsequent requests + increment the count without extending that window." + [cfg kind remote-address limit] + (let [[count] (rds/run! cfg + (fn [{::rds/keys [conn]}] + (rds/eval conn + {::script/name ::rate + ::script/path "app/mcp/oauth_rate.lua" + ::script/keys [(state-key kind (or remote-address "unknown"))] + ::script/vals [60]})))] + (and (number? count) (<= count limit)))) diff --git a/backend/src/app/mcp/oauth_state.lua b/backend/src/app/mcp/oauth_state.lua new file mode 100644 index 0000000000..b7bcd02330 --- /dev/null +++ b/backend/src/app/mcp/oauth_state.lua @@ -0,0 +1,11 @@ +-- Copyright (c) KALEIDOS SUBSIDIARY SL +-- MPL-2.0 +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return {0} +end +if ARGV[2] == '' then + redis.call('DEL', KEYS[1]) +else + redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +end +return {1} diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 09aa9189c2..6681af3cd7 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -12,6 +12,7 @@ [app.db :as db] [app.main :as-alias main] [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] @@ -28,12 +29,23 @@ AND id != ? AND type = 'mcp'") +(def ^:private sql:get-valid-mcp-token + "SELECT * FROM access_token + WHERE profile_id = ? + AND type = 'mcp' + AND (expires_at IS NULL OR expires_at > now()) + ORDER BY created_at DESC + LIMIT 1") + (defn create-access-token "Create an access token with empty perms. Elevated permissions (e.g. error-reports:read) are not assignable via the public API; grant them with SQL or `repl:grant-access-token-perm`." [{:keys [::db/conn] :as cfg} profile-id name expiration type] + ;; Serialize regeneration with OAuth provisioning for the same profile. + (when (= type "mcp") + (db/get-by-id conn :profile profile-id {::db/for-update true})) (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) @@ -62,6 +74,21 @@ (decode-row token))) +(defn ensure-mcp-access! + "Enable MCP and return the profile's unexpired token, creating it if absent. + Must run in a transaction. Lock the profile even when no token exists, so + concurrent approvals and settings regeneration use the same lock. Enabling + MCP must not regenerate a token already held by a connected client; an + expired token is replaced because no client can use it." + [{:keys [::db/conn] :as cfg} profile-id] + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + token (db/exec-one! conn [sql:get-valid-mcp-token profile-id])] + (when-not (:mcp-enabled (:props profile)) + (profile/update-profile-props cfg profile-id {:mcp-enabled true})) + (if token + (decode-row token) + (create-access-token cfg profile-id "MCP" nil "mcp")))) + (defn repl:create-access-token [cfg profile-id name expiration] (db/tx-run! cfg create-access-token profile-id name expiration)) diff --git a/backend/test/backend_tests/mcp_oauth_clients_test.clj b/backend/test/backend_tests/mcp_oauth_clients_test.clj new file mode 100644 index 0000000000..04db71a48b --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_clients_test.clj @@ -0,0 +1,219 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-clients-test + (:require + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest registered-clients + (let [registration (clients/register! {} + {:client_name "Example" + :redirect_uris ["https://client.example/callback"]}) + client (clients/resolve-client {} (:client_id registration))] + (t/is (string? (:client_secret registration))) + (t/is (nil? (:client_secret client))) + (t/is (state/secret= (:secret-hash client) (state/digest (:client_secret registration)))) + (t/is (clients/redirect-matches? client "https://client.example/callback")) + (doseq [redirect ["https://client.example/callback?extra=1" + "https://client.example/callback/../evil" + "https://evil.example/callback"]] + (t/is (not (clients/redirect-matches? client redirect)))))) + +(t/deftest registrations-are-self-contained-and-distinct + (doseq [method ["none" "client_secret_basic" "client_secret_post"]] + (let [metadata {:client_name "Example α" + :logo_uri "https://client.example/logo.png" + :redirect_uris ["https://client.example/callback?source=mcp&mode=oauth"] + :token_endpoint_auth_method method} + first (clients/register! {} metadata) + second (clients/register! {} metadata) + client (clients/resolve-client {} (:client_id first))] + (t/is (not= (:client_id first) (:client_id second))) + (t/is (<= (count (:client_id first)) 2048)) + (t/is (= metadata (select-keys client (keys metadata)))) + (t/is (= (:client_id first) (:client_id client))) + (t/is (nil? (:client_secret client))) + (if (= method "none") + (do (t/is (nil? (:client_secret first))) + (t/is (nil? (:secret-hash client)))) + (t/is (state/secret= (:secret-hash client) + (state/digest (:client_secret first)))))))) + +(t/deftest oversized-registration-is-rejected + (let [metadata {:redirect_uris [(str "https://client.example/" (apply str (repeat 1800 "a")))]}] + (t/is (= "invalid_client_metadata" + (try (clients/register! {} metadata) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest unusable-client-logos-are-ignored + ;; An optional image must not prevent a client from authorizing. + (doseq [logo [nil "" 42 "/logo.png" "http://client.example/logo.png" + "javascript:alert(1)" "data:image/svg+xml," + "https://user:secret@client.example/logo.png" + (str "https://client.example/" (apply str (repeat 2048 "a")))]] + (let [client (clients/validate-metadata + {:redirect_uris ["https://client.example/callback"] + :logo_uri logo} + false)] + (t/is (not (contains? client :logo_uri)))))) + +(t/deftest client-id-size-boundary + (let [callback "https://client.example/callback/" + metadata {:redirect_uris [callback] :token_endpoint_auth_method "none"} + registration (clients/register! {} metadata) + [_ encoded] (str/split (:client_id registration) #"\." 2) + payload-size (alength (.decode (java.util.Base64/getUrlDecoder) encoded)) + ;; 1,525 payload bytes encode to 2,034 characters plus the 14-char prefix. + padding (apply str (repeat (- 1525 payload-size) "a")) + metadata (assoc metadata :redirect_uris [(str callback padding)]) + largest (clients/register! {} metadata)] + (t/is (= 2048 (count (:client_id largest)))) + (t/is (= (:redirect_uris metadata) + (:redirect_uris (clients/resolve-client {} (:client_id largest))))) + (t/is (= "invalid_client_metadata" + (try (clients/register! {} (update-in metadata [:redirect_uris 0] str "a")) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest malformed-client-identifiers-are-rejected + (doseq [id ["unknown" "penpot-mcp-v2.e30" "penpot-mcp-v1.e30" + "penpot-mcp-v1.W10" "penpot-mcp-v1.%%" "penpot-mcp-v1.bm90LWpzb24" + (str "penpot-mcp-v1." (apply str (repeat 2048 "a")))]] + (t/is (= "invalid_client" + (try (clients/resolve-client {} id) + (catch clojure.lang.ExceptionInfo cause (:oauth-error (ex-data cause)))))))) + +(t/deftest client-metadata-validation + (doseq [redirect ["https://client.example/callback#fragment" "javascript:alert(1)" + "http://client.example/callback" "https://user:secret@client.example/cb"]] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata {:redirect_uris [redirect]} false)))) + (t/is (clients/redirect-matches? {:redirect_uris ["http://127.0.0.1:3000/callback"]} + "http://127.0.0.1:12345/callback")) + (t/is (= "none" (:token_endpoint_auth_method + (clients/validate-metadata + {:redirect_uris ["https://chatgpt.com/connector_platform_oauth_redirect"] + :token_endpoint_auth_methods_supported ["none" "private_key_jwt"]} + true)))) + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata + {:redirect_uris ["https://example.com/cb"] + :grant_types ["authorization_code" "refresh_token"]} false)))) + +(t/deftest client-metadata-documents-can-advertise-additional-grants + (let [metadata {:redirect_uris ["https://client.example/callback"] + :grant_types ["authorization_code" "refresh_token"]}] + (t/is (= ["authorization_code"] + (:grant_types (clients/validate-metadata metadata true)))))) + +(t/deftest client-metadata-documents-require-authorization-code-support + (doseq [grants [[] ["refresh_token"] "authorization_code" + ["authorization_code" nil] ["authorization_code" 1]]] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/validate-metadata + {:redirect_uris ["https://client.example/callback"] + :grant_types grants} + true))))) + +(t/deftest localhost-callbacks-allow-only-port-changes + (let [client {:redirect_uris ["http://localhost/callback"]}] + (t/is (clients/redirect-uri? "http://localhost/callback")) + (t/is (clients/redirect-matches? client "http://localhost:52351/callback")) + (doseq [redirect ["http://localhost.evil.example:52351/callback" + "http://127.0.0.1:52351/callback" + "http://localhost:52351/other" + "http://localhost:52351/callback?extra=1" + "http://localhost:52351/callback#fragment" + "http://user@localhost:52351/callback"]] + (t/is (not (clients/redirect-matches? client redirect)))) + (doseq [redirect ["http://localhost.evil.example/callback" + "http://user@localhost/callback"]] + (t/is (not (clients/redirect-uri? redirect)))))) + +(t/deftest codex-published-client-metadata + ;; Public metadata from https://chatgpt.com/oauth/codex/client.json. + ;; Keep this fixture offline so upstream changes cannot make CI flaky. + (let [client-id "https://chatgpt.com/oauth/codex/client.json" + metadata {:client_id client-id + :client_uri "https://chatgpt.com/codex" + :application_type "native" + :redirect_uris ["http://127.0.0.1/callback" "http://localhost/callback"] + :token_endpoint_auth_method "none" + :token_endpoint_auth_methods_supported ["none"] + :grant_types ["authorization_code" "refresh_token"] + :response_types ["code"] + :client_name "Codex" + :logo_uri "https://persistent.oaistatic.com/sonic/misc/openai-logo.png"}] + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return (fn [& _] + {:status 200 + :headers {"content-type" "application/json" "cache-control" "no-store"} + :body (java.io.ByteArrayInputStream. (json/encode metadata))})}] + (let [client (clients/resolve-client th/*system* client-id)] + (t/is (= client-id (:client_id client))) + (t/is (= (:logo_uri metadata) (:logo_uri client))) + (t/is (= ["authorization_code"] (:grant_types client))) + (t/is (= "none" (:token_endpoint_auth_method client))) + (t/is (clients/redirect-matches? client "http://127.0.0.1:52351/callback")))))) + +(t/deftest client-metadata-documents + (let [client-id "https://client.example/client.json" + calls (atom 0) + metadata (atom {:client_id client-id + :client_name "Example" + :redirect_uris ["https://client.example/callback"] + :token_endpoint_auth_methods_supported ["none" "private_key_jwt"]})] + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return (fn [_ _ _] + (swap! calls inc) + {:status 200 + :headers {"content-type" "application/json" "cache-control" "no-store"} + :body (java.io.ByteArrayInputStream. + (json/encode @metadata))})}] + (t/is (= "none" (:token_endpoint_auth_method (clients/resolve-client th/*system* client-id)))) + (swap! metadata assoc :redirect_uris ["https://client.example/changed"]) + (t/is (not (clients/redirect-matches? (clients/resolve-client th/*system* client-id) + "https://client.example/callback"))) + (t/is (= 2 @calls)) + (swap! metadata assoc :client_id "https://different.example/client.json") + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id))) + (swap! metadata assoc :client_id client-id :client_name (apply str (repeat 65537 "a"))) + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id)))))) + +(t/deftest client-metadata-rejects-unsafe-fetches + (with-mocks [sender {:target 'app.http.client/send! + :return (fn [& _] (throw (AssertionError. "Unsafe request reached HTTP client")))}] + (doseq [client-id ["https://127.0.0.1/client.json" + "https://169.254.169.254/client.json" + "https://[::1]/client.json" + "https://user:secret@example.com/client.json" + "https://example.com/client.json#fragment"]] + (t/is (thrown? clojure.lang.ExceptionInfo (clients/resolve-client th/*system* client-id)))) + (t/is (not (:called? @sender))))) + +(t/deftest client-metadata-rejects-redirects + (with-mocks [dns {:target 'app.util.ssrf/resolve-host + :return (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")])} + sender {:target 'app.http.client/send! + :return {:status 302 + :headers {"location" "https://127.0.0.1/internal"} + :body (java.io.ByteArrayInputStream. (byte-array 0))}}] + (t/is (thrown? clojure.lang.ExceptionInfo + (clients/resolve-client th/*system* "https://redirect.example/client.json"))))) diff --git a/backend/test/backend_tests/mcp_oauth_http_test.clj b/backend/test/backend_tests/mcp_oauth_http_test.clj new file mode 100644 index 0000000000..3a19d8bd93 --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_http_test.clj @@ -0,0 +1,253 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-http-test + (:require + [app.common.uri :as uri] + [app.config :as cf] + [app.db :as db] + [app.http :as http] + [app.http.errors :as errors] + [app.http.mcp-oauth :as oauth] + [app.http.session :as session] + [app.mcp.oauth-clients :as clients] + [app.mcp.oauth-state :as state] + [app.rpc.commands.access-token :as access-token] + [app.setup.clock :as clock] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [yetti.response :as yres]) + (:import java.net.URLEncoder java.nio.charset.StandardCharsets)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset + (fn [run] + (binding [cf/config (assoc cf/config :tenant (str (java.util.UUID/randomUUID)))] + (run)))) + +(defn- form [params] + (str/join "&" (for [[key value] params] + (str (name key) "=" (URLEncoder/encode (str value) StandardCharsets/UTF_8))))) + +(defn- request [path & {:as options}] + (let [handler (#'http/router-handler (:app.http/router th/*system*))] + (handler (th/make-dummy-request (assoc options :path path))))) + +(defn- body [response] (json/decode (::yres/body response))) + +(defn- login-cookie [profile] + (let [session (session/create-session (:app.http.session/manager th/*system*) + {:profile-id (:id profile)}) + token (:token (#'session/assign-token th/*system* session))] + {(cf/get :auth-token-cookie-name) token})) + +(defn- begin [client] + (let [reply (request "/api/mcp/oauth/authorize" + :query (form {:client_id (:client_id client) + :redirect_uri (first (:redirect_uris client)) + :response_type "code" :scope "mcp" + :resource (oauth/resource) + :state "client-state" + :code_challenge (state/digest (apply str (repeat 43 "a"))) + :code_challenge_method "S256"}))] + (t/is (= 303 (::yres/status reply))) + (get (uri/query-string->map (.getQuery (java.net.URI. (get-in reply [::yres/headers "location"])))) + :request))) + +(t/deftest discovery-and-registration + (let [metadata (body (request "/.well-known/oauth-authorization-server/api/mcp/oauth")) + resource (body (request "/api/mcp/oauth/resource")) + registered (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes (json/encode-str {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}))] + (t/is (= (oauth/issuer) (:issuer metadata))) + (t/is (= ["S256"] (:code_challenge_methods_supported metadata))) + (t/is (= (oauth/resource) (:resource resource))) + (t/is (= 201 (::yres/status registered))) + (t/is (= "none" (:token_endpoint_auth_method (body registered)))) + (t/is (nil? (:client_secret (body registered)))))) + +(t/deftest complete-authorization-flow + (let [profile (th/create-profile* 1 {:is-active true}) + cookies (login-cookie profile) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :logo_uri "https://example.com/logo.png" + :token_endpoint_auth_method "none"}) + handle (begin client) + details (request "/api/mcp/oauth/request" :query (form {:request handle}) :cookies cookies) + origin (str (cf/get-public-uri)) + origin (str/replace origin #"/$" "") + decision {:request handle :decision "approve" :csrf (:csrf (body details))} + submit #(request "/api/mcp/oauth/decision" :method :post :cookies cookies + :headers {"content-type" "application/x-www-form-urlencoded" "origin" origin} + :body-bytes (form %))] + (t/is (= 401 (::yres/status (request "/api/mcp/oauth/request" :query (form {:request handle}))))) + (t/is (= 200 (::yres/status details))) + (t/is (= "https://example.com/logo.png" (:logo-uri (body details)))) + (t/is (= 400 (::yres/status (submit (assoc decision :csrf "wrong"))))) + (let [approved (submit decision) + callback (java.net.URI. (:redirect-uri (body approved))) + params (uri/query-string->map (.getQuery callback)) + exchange {:grant_type "authorization_code" :code (:code params) + :client_id (:client_id client) :redirect_uri "https://example.com/callback" + :resource (oauth/resource) :code_verifier (apply str (repeat 43 "a"))} + redeem #(request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes (form %))] + (t/is (= 200 (::yres/status approved))) + (t/is (= "client-state" (:state params))) + (t/is (= (oauth/issuer) (:iss params))) + (t/is (= 400 (::yres/status (submit decision)))) + ;; Metadata is public and editable, but changing it changes the client ID. + ;; Even with the correct verifier, an edited ID cannot use this approval. + (let [[prefix encoded] (str/split (:client_id client) #"\." 2) + payload (json/decode (.decode (java.util.Base64/getUrlDecoder) encoded))] + (doseq [changed [(assoc payload :client_name "Different application") + (assoc payload :redirect_uris ["https://attacker.example/callback"])]] + (let [id (str prefix "." (.encodeToString (.withoutPadding (java.util.Base64/getUrlEncoder)) + (json/encode changed)))] + (t/is (string? (:client_id (clients/resolve-client {} id)))) + (t/is (= "invalid_grant" (:error (body (redeem (assoc exchange :client_id id))))))))) + (t/is (= 400 (::yres/status (redeem (assoc exchange :code_verifier (apply str (repeat 43 "b"))))))) + (let [token-response (redeem exchange) + token (body token-response) + stored (first (db/query th/*pool* :access-token {:profile-id (:id profile) :type "mcp"}))] + (t/is (= 200 (::yres/status token-response))) + (t/is (= "no-store" (get-in token-response [::yres/headers "cache-control"]))) + (t/is (= (:token stored) (:access_token token))) + (t/is (= "Bearer" (:token_type token))) + (t/is (nil? (:expires_in token))) + (t/is (nil? (:refresh_token token))) + (t/is (= 400 (::yres/status (redeem exchange)))))))) + +(t/deftest denial-and-session-binding + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + cookies (login-cookie profile) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}) + handle (begin client) + details (body (request "/api/mcp/oauth/request" :query (form {:request handle}) :cookies cookies)) + params {:request handle :csrf (:csrf details) :decision "deny"} + submit #(request "/api/mcp/oauth/decision" :method :post :cookies cookies + :headers {"content-type" "application/x-www-form-urlencoded" "origin" %} + :body-bytes (form params))] + (t/is (= 403 (::yres/status + (request "/api/mcp/oauth/request" :query (form {:request handle}) + :cookies (login-cookie other))))) + (t/is (= 403 (::yres/status (submit "https://attacker.example")))) + (let [denied (submit (str/replace (str (cf/get-public-uri)) #"/$" "")) + callback (java.net.URI. (:redirect-uri (body denied))) + result (uri/query-string->map (.getQuery callback))] + (t/is (= "access_denied" (:error result))) + (t/is (= "client-state" (:state result))) + (t/is (empty? (db/query th/*pool* :access-token {:profile-id (:id profile)}))) + (t/is (not (:mcp-enabled (db/decode-transit-pgobject + (:props (th/db-get :profile {:id (:id profile)}))))))))) + +(t/deftest oauth-input-and-cors-errors + (doseq [data ["{" "[]" (apply str (repeat 16385 "a"))]] + (let [reply (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes data)] + (t/is (= 400 (::yres/status reply))) + (t/is (= "*" (get-in reply [::yres/headers "access-control-allow-origin"]))))) + (let [reply (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes "code=a&code=b")] + (t/is (= 400 (::yres/status reply)))) + (t/is (= 204 (::yres/status (request "/api/mcp/oauth/token" :method :options))))) + +(t/deftest confidential-client-exchange + (let [profile (th/create-profile* 1 {:is-active true}) + stored (db/tx-run! th/*system* access-token/ensure-mcp-access! (:id profile)) + verifier (apply str (repeat 43 "a"))] + (doseq [method ["client_secret_basic" "client_secret_post"]] + (let [client (clients/register! th/*system* + {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method method}) + code (state/create! th/*system* :code + {:client-id (:client_id client) + :redirect-uri "https://example.com/callback" + :resource (oauth/resource) + :challenge (state/digest verifier) + :profile-id (str (:id profile)) + :token-id (str (:id stored))} + 120) + params {:grant_type "authorization_code" :client_id (:client_id client) + :code code :code_verifier verifier :redirect_uri "https://example.com/callback"} + redeem (fn [secret] + (request "/api/mcp/oauth/token" :method :post + :headers (cond-> {"content-type" "application/x-www-form-urlencoded"} + (= method "client_secret_basic") + (assoc "authorization" + (str "Basic " (.encodeToString + (java.util.Base64/getEncoder) + (.getBytes (str (:client_id client) ":" secret) "UTF-8"))))) + :body-bytes (form (cond-> params + (= method "client_secret_post") + (assoc :client_secret secret)))))] + (t/is (= 401 (::yres/status (redeem "wrong")))) + (t/is (= (:token stored) (:access_token (body (redeem (:client_secret client)))))))))) + +(t/deftest exchange-rejects-expired-token + (let [profile (th/create-profile* 1 {:is-active true}) + stored (db/tx-run! th/*system* access-token/ensure-mcp-access! (:id profile)) + verifier (apply str (repeat 43 "a")) + client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"] + :token_endpoint_auth_method "none"}) + code (state/create! th/*system* :code + {:client-id (:client_id client) + :redirect-uri "https://example.com/callback" + :resource (oauth/resource) + :challenge (state/digest verifier) + :profile-id (str (:id profile)) + :token-id (str (:id stored))} + 120)] + (db/exec! th/*pool* ["UPDATE access_token SET expires_at = now() - interval '1 day' WHERE id = ?" + (:id stored)]) + (let [reply (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes (form {:grant_type "authorization_code" :code code + :client_id (:client_id client) :code_verifier verifier + :redirect_uri "https://example.com/callback"}))] + (t/is (= 400 (::yres/status reply))) + (t/is (= "invalid_grant" (:error (body reply))))))) + +(t/deftest unexpected-errors-are-logged + (let [logged (atom []) + cookies (login-cookie (th/create-profile* 1 {:is-active true})) + fail (fn [& _] (throw (RuntimeException. "unexpected")))] + (with-redefs [errors/handle (fn [cause _] (swap! logged conj (ex-message cause))) + clients/register! fail + clock/get-clock fail] + (t/testing "endpoint failures" + (let [reply (request "/api/mcp/oauth/register" :method :post + :headers {"content-type" "application/json"} + :body-bytes (json/encode-str {:redirect_uris ["https://example.com/callback"]}))] + (t/is (= 503 (::yres/status reply))) + (t/is (= {:error "temporarily_unavailable"} (body reply))) + (t/is (= "*" (get-in reply [::yres/headers "access-control-allow-origin"]))))) + (t/testing "session middleware failures" + (let [reply (request "/api/mcp/oauth/request" :query (form {:request "x"}) :cookies cookies)] + (t/is (= 503 (::yres/status reply))) + (t/is (= {:error "temporarily_unavailable"} (body reply))))) + (t/testing "OAuth rejections are not logged" + (t/is (= 400 (::yres/status (request "/api/mcp/oauth/token" :method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :body-bytes "code=a&code=b")))))) + (t/is (= ["unexpected" "unexpected"] @logged)))) + +(t/deftest authorization-rejects-unregistered-callback + (let [client (clients/register! th/*system* {:redirect_uris ["https://example.com/callback"]}) + reply (request "/api/mcp/oauth/authorize" + :query (form {:client_id (:client_id client) + :redirect_uri "https://attacker.example/callback"}))] + (t/is (= 400 (::yres/status reply))) + (t/is (nil? (get-in reply [::yres/headers "location"]))))) diff --git a/backend/test/backend_tests/mcp_oauth_state_test.clj b/backend/test/backend_tests/mcp_oauth_state_test.clj new file mode 100644 index 0000000000..196c8378a2 --- /dev/null +++ b/backend/test/backend_tests/mcp_oauth_state_test.clj @@ -0,0 +1,53 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.mcp-oauth-state-test + (:require + [app.config :as cf] + [app.mcp.oauth-state :as state] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) + +(t/deftest authorization-state + (let [cfg th/*system* + handle (state/create! cfg :code {:client-id "test"} 60) + record (state/read! cfg :code handle)] + (t/is (= {:client-id "test"} (:value record))) + (t/is (nil? (state/read! cfg :request handle))) + (t/is (false? (state/consume! cfg :code handle (assoc record :raw "wrong")))) + (t/is (= record (state/read! cfg :code handle))) + (let [results (mapv deref (repeatedly 8 #(future (state/consume! cfg :code handle record))))] + (t/is (= 1 (count (filter true? results))))) + (t/is (nil? (state/read! cfg :code handle))))) + +(t/deftest bind-pending-request-once + (let [cfg th/*system* + handle (state/create! cfg :request {:client-id "test"} 60) + record (state/read! cfg :request handle)] + (t/is (true? (state/replace! cfg :request handle record {:session-id "first"}))) + (t/is (false? (state/replace! cfg :request handle record {:session-id "second"}))) + (t/is (= {:session-id "first"} (:value (state/read! cfg :request handle)))))) + +(t/deftest pkce-and-secrets + ;; RFC 7636 appendix B + (t/is (= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + (state/digest "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")))) + +(t/deftest expiry-and-tenant-isolation + (let [cfg th/*system* + handle (state/create! cfg :request {:test true} 1)] + (binding [cf/config (assoc cf/config :tenant "another-tenant")] + (t/is (nil? (state/read! cfg :request handle)))) + (Thread/sleep 1100) + (t/is (nil? (state/read! cfg :request handle))))) + +(t/deftest state-storage-fails-closed + (with-mocks [store {:target 'app.redis/set :return nil}] + (t/is (thrown? clojure.lang.ExceptionInfo + (state/create! th/*system* :request {:test true} 60))))) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index 5fa3a1496a..e5fcaf5348 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -11,6 +11,7 @@ [app.http :as http] [app.http.access-token :as actoken] [app.rpc :as-alias rpc] + [app.rpc.commands.access-token :as access-token] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] @@ -232,3 +233,40 @@ decoded (actoken/decode-token cfg access-token)] (t/is (some? decoded)) (t/is (= (:id prof) (:uid decoded))))))) + +(t/deftest oauth-mcp-token-provisioning + (let [prof (th/create-profile* 1 {:is-active true}) + cfg th/*system* + provision #(db/tx-run! cfg access-token/ensure-mcp-access! (:id prof))] + (t/testing "approval enables MCP and creates a fixed token" + (let [token (provision) + props (-> (th/db-get :profile {:id (:id prof)}) + :props db/decode-transit-pgobject)] + (t/is (string? (:token token))) + (t/is (nil? (:expires-at token))) + (t/is (true? (:mcp-enabled props))) + (t/is (= token (provision))))) + (t/testing "approval preserves a token generated in settings" + (let [{:keys [result error]} + (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "From settings"})] + (t/is (nil? error)) + (t/is (= (:token result) (:token (provision)))))) + (t/testing "approval replaces an expired token" + (db/exec! th/*pool* [(str "UPDATE access_token SET expires_at = now() - interval '1 day'" + " WHERE profile_id = ?") + (:id prof)]) + (let [expired (first (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})) + token (provision)] + (t/is (not= (:token expired) (:token token))) + (t/is (nil? (:expires-at token))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})))))) + (t/testing "concurrent approvals share one token" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [results (mapv deref (repeatedly 4 #(future (provision))))] + (t/is (= 1 (count (set (map :token results))))) + (t/is (= 1 (count (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"})))))))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index e684d5b7e0..f15a261040 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -175,14 +175,14 @@ (defn- import-file [profile path] - (let [out (th/command! {::th/type :import-binfile - ::rpc/profile-id (:id profile) - :project-id (:default-project-id profile) - :name "imported" - :file {:filename "imported.penpot" - :size (fs/size path) - :path path - :mtype "application/zip"}})] + (let [out (th/multipart-command! {::th/type :import-binfile + ::rpc/profile-id (:id profile) + :project-id (:default-project-id profile) + :name "imported" + :file {:filename "imported.penpot" + :size (fs/size path) + :path path + :mtype "application/zip"}})] (t/is (nil? (:error out))) (th/consume-sse (:result out)))) diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 5a0a81f091..9a3cfd3222 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -141,6 +141,11 @@ http { proxy_pass http://127.0.0.1:6061; } + location = /.well-known/oauth-authorization-server/api/mcp/oauth { + proxy_pass http://127.0.0.1:6060; + proxy_http_version 1.1; + } + location /api { proxy_pass http://127.0.0.1:6060/api; proxy_http_version 1.1; diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index c8800b0503..54d62e18eb 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -155,6 +155,11 @@ http { proxy_pass $PENPOT_EXPORTER_URI; } + location = /.well-known/oauth-authorization-server/api/mcp/oauth { + proxy_set_header Host $proxy_host; + proxy_pass $PENPOT_BACKEND_URI$request_uri; + } + location /api { proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/api; diff --git a/frontend/playwright/ui/specs/email-verification.spec.js b/frontend/playwright/ui/specs/email-verification.spec.js index 65c1a9cb99..f15be88e20 100644 --- a/frontend/playwright/ui/specs/email-verification.spec.js +++ b/frontend/playwright/ui/specs/email-verification.spec.js @@ -1,32 +1,33 @@ import { test, expect } from "@playwright/test"; import { RegisterPage } from "../pages/RegisterPage"; -// Regression test for the bug where a freshly verified account (whose -// profile never had a theme persisted) ended up with an empty string as -// its theme instead of falling back to the dark default: the workspace -// switched to light mode and Settings > UI Theme showed a blank field. +// A freshly verified account has no saved theme preference. It must follow +// the system theme and show that preference in settings, not a blank field. test.beforeEach(async ({ page }) => { await RegisterPage.initWithLoggedOutUser(page); }); test.describe("Email verification", () => { - test("Newly verified account defaults to the dark theme", async ({ - page, - }) => { - const registerPage = new RegisterPage(page); - await registerPage.setupEmailVerificationSuccess(); + for (const colorScheme of ["light", "dark"]) { + test(`Newly verified account follows the ${colorScheme} system theme`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme }); + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); - await registerPage.goToVerifyToken(); - await page.waitForURL(/screen=dashboard/); + await registerPage.goToVerifyToken(); + await page.waitForURL(/screen=dashboard/); - // `default` is the body class applied for dark theme, `light` for - // light theme (see app.util.theme/set-color-scheme). - await expect(page.locator("body")).toHaveClass(/default/); - await expect(page.locator("body")).not.toHaveClass(/light/); - }); + // The dark theme uses the legacy body class "default". + await expect(page.locator("body")).toHaveClass( + colorScheme === "dark" ? "default" : "light", + ); + }); + } - test("Settings > UI Theme shows Penpot Dark (default) selected, not blank", async ({ + test("Settings > UI Theme shows System theme selected, not blank", async ({ page, }) => { const registerPage = new RegisterPage(page); @@ -40,6 +41,6 @@ test.describe("Email verification", () => { // The language select is the first combobox on the page, the theme // select is the second one. const themeSelect = page.getByRole("combobox").nth(1); - await expect(themeSelect).toHaveText("Penpot Dark (default)"); + await expect(themeSelect).toHaveText("System theme"); }); }); diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index 051530b877..b59266f5eb 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -107,6 +107,8 @@ test("Selection size badge appears on selection and hides on deselect", async ({ test("Selection size badge uses component color for component selection", async ({ page, }) => { + // The expected component color below is the dark palette value. + await page.emulateMedia({ colorScheme: "dark" }); const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); await workspacePage.mockGetFile("components/get-file-13267.json"); diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index bbae3fec99..5e2075082c 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -20,6 +20,7 @@ [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.exports.files] [app.main.ui.frame-preview :as frame-preview] + [app.main.ui.mcp-authorize :refer [mcp-authorize*]] [app.main.ui.nitrate.entry :as nitrate-entry] [app.main.ui.notifications :as notifications] [app.main.ui.onboarding.questions :refer [questions-modal]] @@ -118,6 +119,9 @@ :auth-verify-token [:? [:> verify-token-page* {:route route}]] + :mcp-authorize + [:> mcp-authorize* {:route route :profile profile}] + :nitrate-entry [:> nitrate-entry/nitrate-entry-page* {:profile profile}] diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index d200c3746d..b43a954986 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -70,7 +70,7 @@ [:string {:min 1}]]]) (mf/defc login-form* - [{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}] + [{:keys [params handle-redirect on-success-callback on-recovery-request origin hide-recovery-link] :as props}] (let [initial (mf/with-memo [params] params) form (fm/use-form :schema schema:login-form @@ -193,7 +193,8 @@ :label (tr "auth.password") :class (stl/css :form-field)}]]) - (when (and (not= origin :viewer) + (when (and (not hide-recovery-link) + (not= origin :viewer) (or (contains? cf/flags :login) (contains? cf/flags :login-with-password))) [:div {:class (stl/css :form-row :forgot-password-row)} diff --git a/frontend/src/app/main/ui/auth/login_modal.cljs b/frontend/src/app/main/ui/auth/login_modal.cljs new file mode 100644 index 0000000000..ee192893a4 --- /dev/null +++ b/frontend/src/app/main/ui/auth/login_modal.cljs @@ -0,0 +1,146 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.auth.login-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.config :as cf] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.auth.login :refer [login-dialog*]] + [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] + [app.main.ui.auth.register :refer [register-methods* register-success-page* + register-validate-form* terms-service-privacy-policy*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as k] + [rumext.v2 :as mf])) + +(mf/defc login-register-modal* + [{:keys [params on-close on-register]}] + (let [user-email (mf/use-state "") + register-token (mf/use-state "") + + current-section* (mf/use-state :login) + current-section (deref current-section*) + + set-current-section + (mf/use-fn + #(reset! current-section* %)) + + set-section + (mf/use-fn + (fn [event] + (let [section (-> (dom/get-current-target event) + (dom/get-data "value") + (keyword))] + (set-current-section section)))) + + go-back-to-login + (mf/use-fn + #(set-current-section :login)) + + main-section (or + (= current-section :login) + (= current-section :register) + (= current-section :register-validate)) + close + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (on-close)) + + success-email-sent + (fn [email] + (reset! user-email email) + (set-current-section :email-sent)) + + success-login + (fn [& _] + (.reload js/window.location true)) + + success-register + (fn [data] + (cond + (:invitation-token data) + (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)})) + + (:is-active data) + (if on-register (on-register) (on-close)) + + :else + (do + (reset! user-email (:email data)) + (set-current-section :email-sent))))] + + [:div {:class (stl/css :modal-overlay) + :on-key-down #(when (k/esc? %) (close %))} + [:div {:class (stl/css :modal-container) + :role "dialog" + :aria-modal true + :aria-labelledby "login-modal-title"} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-header-title) + :id "login-modal-title"} (tr "labels.continue-with-penpot")] + [:> icon-button* {:variant "ghost" + :class (stl/css :modal-close) + :aria-label (tr "labels.close") + :on-click close + :icon i/close}]] + + [:div {:class (stl/css :modal-content)} + (case current-section + :login + [:div {:class (stl/css :login-form)} + [:> login-dialog* {:params params + :on-success-callback success-login + :hide-recovery-link true}] + [:div {:class (stl/css :login-links)} + [:div + [:a {:on-click set-section + :data-value "recovery-request"} + (tr "auth.forgot-password")]] + (when (contains? cf/flags :registration) + [:div + [:span + (tr "auth.register") " "] + [:a {:on-click set-section + :data-value "register"} + (tr "auth.register-submit")]])]] + + :register + [:div {:class (stl/css :login-form)} + [:> register-methods* {:params params :on-success-callback success-register}] + [:div {:class (stl/css :login-links)} + [:div + [:span (tr "auth.already-have-account") " "] + [:a {:on-click set-section + :data-value "login"} + (tr "auth.login-here")]]]] + + :register-validate + [:div {:class (stl/css :login-form)} + [:> register-validate-form* {:params {:token @register-token} + :on-success-callback success-email-sent}] + [:div {:class (stl/css :login-links)} + [:div + [:a {:on-click set-section + :data-value "register"} + (tr "labels.go-back")]]]] + + :recovery-request + [:> recovery-request-page* {:go-back-callback go-back-to-login + :on-success-callback success-email-sent}] + + :email-sent + [:div {:class (stl/css :login-form)} + [:> register-success-page* {:params {:email @user-email}}]]) + + (when main-section + [:div {:class (stl/css :login-links)} + [:> terms-service-privacy-policy*]])]]])) diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/auth/login_modal.scss similarity index 100% rename from frontend/src/app/main/ui/viewer/login.scss rename to frontend/src/app/main/ui/auth/login_modal.scss diff --git a/frontend/src/app/main/ui/components/progress.cljs b/frontend/src/app/main/ui/components/progress.cljs index 52fb0b2393..35ae9f3101 100644 --- a/frontend/src/app/main/ui/components/progress.cljs +++ b/frontend/src/app/main/ui/components/progress.cljs @@ -31,8 +31,8 @@ [] (let [state (mf/deref refs/progress) profile (mf/deref refs/profile) - theme (get profile :theme theme/default) - default-theme? (= theme/default theme) + theme (theme/use-theme profile) + dark-theme? (= "dark" theme) error? (:error state) healthy? (:healthy state) visible? (:visible state) @@ -48,13 +48,13 @@ color (cond error? clr/new-danger - healthy? (if default-theme? + healthy? (if dark-theme? clr/new-primary clr/new-primary-light) (not healthy?) clr/new-warning) background-clr - (if default-theme? + (if dark-theme? clr/background-quaternary clr/background-quaternary-light) diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index eb842c1f6b..6f7f2311aa 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -207,8 +207,8 @@ [] (let [state (mf/deref refs/export) profile (mf/deref refs/profile) - theme (or (:theme profile) theme/default) - is-default-theme? (= theme/default theme) + theme (theme/use-theme profile) + dark-theme? (= "dark" theme) error? (:error state) ;; The exporter is at capacity: worth its own wording, so the user ;; knows retrying later is the thing to do. @@ -245,13 +245,13 @@ error? clr/new-danger (or cancelling? cancelled?) clr/new-warning - healthy? (if is-default-theme? + healthy? (if dark-theme? clr/new-primary clr/new-primary-light) (not healthy?) clr/new-warning) background-clr - (if is-default-theme? + (if dark-theme? clr/background-quaternary clr/background-quaternary-light) diff --git a/frontend/src/app/main/ui/mcp_authorize.cljs b/frontend/src/app/main/ui/mcp_authorize.cljs new file mode 100644 index 0000000000..5f47fd5df6 --- /dev/null +++ b/frontend/src/app/main/ui/mcp_authorize.cljs @@ -0,0 +1,162 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.mcp-authorize + (:require-macros [app.main.style :as stl]) + (:require + [app.common.uri :as uri] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.profile :as profile] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.auth.login-modal :refer [login-register-modal*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.typography :as typography] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.product.loader :refer [loader*]] + [app.util.http :as http] + [app.util.i18n :refer [tr]] + [app.util.storage :as storage] + [beicon.v2.core :as rx] + [rumext.v2 :as mf])) + +(defn- endpoint + [path] + (str (uri/join cf/public-uri (str "api/mcp/oauth/" path)))) + +(defn- prepare-login! + [] + (let [href (rt/get-current-href)] + ;; Save the return URL even when only SSO buttons are enabled and the + ;; password form (which normally stores it) never mounts. + (binding [storage/*sync* true] + (swap! storage/session assoc :login-redirect href)) + {:callback-url href})) + +(defn- cancel-login! + [] + ;; Do not let a later, unrelated login resume this cancelled attempt. + (when (= (:login-redirect storage/session) (rt/get-current-href)) + (binding [storage/*sync* true] + (swap! storage/session dissoc :login-redirect)))) + +(defn- oauth-request + [method path params] + (->> (http/send! (cond-> {:method method + :uri (endpoint path) + :response-type :json + :omit-default-headers true} + (= method :get) (assoc :query params) + (= method :post) (assoc :headers {"content-type" "application/x-www-form-urlencoded"} + :body (uri/map->query-string params)))) + (rx/map #(update % :body js->clj :keywordize-keys true)))) + +(mf/defc client-logo* + {::mf/private true} + [{:keys [uri]}] + (let [failed (mf/use-state false)] + (when-not @failed + [:img {:class (stl/css :client-logo) + :src uri + :alt "" + :referrer-policy "no-referrer" + :on-error #(reset! failed true)}]))) + +(mf/defc mcp-authorize* + [{:keys [route profile]}] + (let [handle (get-in route [:query-params :request]) + details (mf/use-state nil) + failed? (mf/use-state false) + login-params (mf/use-state nil) + cancelled? (mf/use-state false) + busy? (mf/use-state false) + ;; The ref also blocks a second click before React renders disabled buttons. + submitting (mf/use-memo (fn [] (atom false))) + + decide! + (mf/use-fn + (mf/deps handle @details) + (fn [decision] + (when (compare-and-set! submitting false true) + (reset! busy? true) + (->> (oauth-request :post "decision" + {:request handle :csrf (:csrf @details) :decision decision}) + (rx/subs! + (fn [{:keys [status body]}] + (if (= status 200) + (do + (when (= decision "approve") + (st/emit! (profile/refresh-profile) + (mbc/event :mcp/enable {}))) + (st/emit! (rt/nav-raw :href (:redirect-uri body)))) + (reset! failed? true))) + (fn [_] (reset! failed? true)))))))] + + (mf/with-effect [handle] + (reset! details nil) + (reset! failed? false) + (reset! login-params nil) + (reset! cancelled? false) + (reset! busy? false) + (reset! submitting false) + (let [subscription + (->> (oauth-request :get "request" {:request handle}) + (rx/subs! + (fn [{:keys [status body]}] + (case status + 200 (reset! details body) + 401 (reset! login-params (prepare-login!)) + (reset! failed? true))) + (fn [_] (reset! failed? true))))] + #(rx/dispose! subscription))) + + [:main {:class (stl/css :page)} + (if @login-params + [:> login-register-modal* + {:params @login-params + :on-register #(st/emit! (rt/reload true)) + :on-close (fn [] + (cancel-login!) + (reset! login-params nil) + (reset! cancelled? true))}] + [:section {:class (stl/css :card) :aria-labelledby "mcp-approval-title"} + [:> heading* {:level 1 :typography typography/title-large :id "mcp-approval-title"} + (tr "mcp.oauth.title")] + (cond + @cancelled? + [:> text* {:typography typography/body-large :role "status"} + (tr "mcp.oauth.cancelled")] + + @failed? + [:> text* {:typography typography/body-large :role "alert"} + (tr "mcp.oauth.failed")] + + (nil? @details) + [:> loader* {:title (tr "labels.loading")}] + + :else + [:* + [:div {:class (stl/css :client)} + (when-let [logo-uri (:logo-uri @details)] + [:> client-logo* {:key logo-uri :uri logo-uri}]) + [:div {:class (stl/css :client-details)} + [:> text* {:typography typography/body-large :class (stl/css :client-text)} + (tr "mcp.oauth.request" (or (:client-name @details) (:client-id @details)))] + [:> text* {:typography typography/body-small :class (stl/css :client-text)} + (:client-id @details)]]] + [:> text* {:typography typography/body-medium} + (tr "mcp.oauth.account" (:email profile))] + [:> text* {:typography typography/body-large} + (tr "mcp.oauth.access")] + [:> text* {:typography typography/body-medium} + (tr "mcp.oauth.enable")] + [:div {:class (stl/css :actions)} + [:> button* {:variant "secondary" :disabled @busy? :on-click #(decide! "deny")} + (tr "labels.cancel")] + [:> button* {:variant "primary" :disabled @busy? :on-click #(decide! "approve")} + (tr "mcp.oauth.approve")]]])])])) diff --git a/frontend/src/app/main/ui/mcp_authorize.scss b/frontend/src/app/main/ui/mcp_authorize.scss new file mode 100644 index 0000000000..64eed408fa --- /dev/null +++ b/frontend/src/app/main/ui/mcp_authorize.scss @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_sizes.scss" as *; + +.page { + display: grid; + place-items: center; + min-block-size: 100%; + padding: var(--sp-xl); + background: var(--color-background-primary); + color: var(--color-foreground-primary); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--sp-l); + max-inline-size: 36rem; + padding: var(--sp-xxl); + border: 1px solid var(--color-background-quaternary); + border-radius: var(--br-8); +} + +.client { + display: flex; + align-items: center; + gap: var(--sp-l); + margin-block-end: var(--sp-s); + overflow-wrap: anywhere; +} + +.client-logo { + inline-size: $sz-48; + block-size: $sz-48; + flex-shrink: 0; + object-fit: contain; + border-radius: var(--br-8); +} + +.client-details { + display: flex; + flex-direction: column; + gap: calc(var(--sp-s) - var(--sp-xxs)); + min-inline-size: 0; +} + +.client-text { + margin: 0; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-m); + margin-block-start: var(--sp-l); +} diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 8096051d40..fa9926911b 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -33,7 +33,8 @@ "Enabled route names. Query-string routing: the `screen` query param carries the route name (`?screen=¶ms`); it is router-owned and reserved, every other param travels as a plain query param." - (into #{:auth-login + (into #{:mcp-authorize + :auth-login :auth-register :auth-register-validate :auth-register-success diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index c4d711ba79..12ba30ef6a 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -49,7 +49,7 @@ (update :lang #(or % "")) (update :theme #(if (= % "default") "dark" - (or % "dark"))))) + (or % theme/default))))) form (fm/use-form :schema schema:options-form :initial initial)] diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index e99e7e3383..fca78a1aa4 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -5,140 +5,13 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.login - (:require-macros [app.main.style :as stl]) (:require - [app.common.logging :as log] - [app.config :as cf] [app.main.data.modal :as modal] - [app.main.router :as rt] - [app.main.store :as st] - [app.main.ui.auth.login :refer [login-dialog*]] - [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-methods* register-success-page* - register-validate-form* terms-service-privacy-policy*]] - [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.main.ui.auth.login-modal :refer [login-register-modal*]] [rumext.v2 :as mf])) -(log/set-level! :warn) - (mf/defc login-register-modal {::mf/register modal/components ::mf/register-as :login-register} [_] - (let [user-email (mf/use-state "") - register-token (mf/use-state "") - - current-section* (mf/use-state :login) - current-section (deref current-section*) - - set-current-section - (mf/use-fn - #(reset! current-section* %)) - - set-section - (mf/use-fn - (fn [event] - (let [section (-> (dom/get-current-target event) - (dom/get-data "value") - (keyword))] - (set-current-section section)))) - - go-back-to-login - (mf/use-fn - #(set-current-section :login)) - - main-section (or - (= current-section :login) - (= current-section :register) - (= current-section :register-validate)) - close - (fn [event] - (dom/prevent-default event) - (st/emit! (modal/hide))) - - success-email-sent - (fn [email] - (reset! user-email email) - (set-current-section :email-sent)) - - success-login - (fn [] - (.reload js/window.location true)) - - success-register - (fn [data] - (cond - (:invitation-token data) - (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)})) - - (:is-active data) - (st/emit! (modal/hide)) - - :else - (do - (reset! user-email (:email data)) - (set-current-section :email-sent))))] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-header-title)} (tr "labels.continue-with-penpot")] - [:> icon-button* {:variant "ghost" - :class (stl/css :modal-close) - :aria-label (tr "labels.close") - :on-click close - :icon i/close}]] - - [:div {:class (stl/css :modal-content)} - (case current-section - :login - [:div {:class (stl/css :login-form)} - [:> login-dialog* {:on-success-callback success-login - :origin :viewer}] - [:div {:class (stl/css :login-links)} - [:div - [:a {:on-click set-section - :data-value "recovery-request"} - (tr "auth.forgot-password")]] - (when (contains? cf/flags :registration) - [:div - [:span - (tr "auth.register") " "] - [:a {:on-click set-section - :data-value "register"} - (tr "auth.register-submit")]])]] - - :register - [:div {:class (stl/css :login-form)} - [:> register-methods* {:on-success-callback success-register}] - [:div {:class (stl/css :login-links)} - [:div - [:span (tr "auth.already-have-account") " "] - [:a {:on-click set-section - :data-value "login"} - (tr "auth.login-here")]]]] - - :register-validate - [:div {:class (stl/css :login-form)} - [:> register-validate-form* {:params {:token @register-token} - :on-success-callback success-email-sent}] - [:div {:class (stl/css :login-links)} - [:div - [:a {:on-click set-section - :data-value "register"} - (tr "labels.go-back")]]]] - - :recovery-request - [:> recovery-request-page* {:go-back-callback go-back-to-login - :on-success-callback success-email-sent}] - - :email-sent - [:div {:class (stl/css :login-form)} - [:> register-success-page* {:params {:email @user-email}}]]) - - (when main-section - [:div {:class (stl/css :login-links)} - [:> terms-service-privacy-policy*]])]]])) + [:> login-register-modal* {:on-close modal/hide!}]) diff --git a/frontend/src/app/util/theme.cljs b/frontend/src/app/util/theme.cljs index 75c0837d40..b5c0437a6e 100644 --- a/frontend/src/app/util/theme.cljs +++ b/frontend/src/app/util/theme.cljs @@ -17,7 +17,7 @@ (defonce ^:private color-scheme-listeners* (atom #{})) -(def ^:const default "dark") +(def ^:const default "system") (defn get-system-theme [] @@ -47,16 +47,17 @@ (defn resolve-theme "Resolves the profile's theme setting to the effective UI theme ('dark' or - 'light'): 'system' follows the given system theme, 'default' and an unset - theme mean dark, and any other value is taken as-is. Single source of truth - for the app's own theme and the theme reported to plugins." + 'light'). An unset preference or 'system' follows the system theme; + the legacy 'default' preference means dark. Used by the app and plugins." [profile-theme system-theme] - (cond - (= profile-theme "system") system-theme - (= profile-theme "default") "dark" - :else (d/nilv profile-theme "dark"))) + (let [preference (d/nilv profile-theme default)] + (case preference + "system" system-theme + "default" "dark" + preference))) -(defn use-initialize +(defn use-theme + "Return the effective theme and follow system preference changes." [{profile-theme :theme}] (let [system-theme* (mf/use-state get-system-theme) system-theme (deref system-theme*)] @@ -68,6 +69,11 @@ (fn [] (rx/dispose! s)))) - (mf/with-effect [system-theme profile-theme] - (set-color-scheme (resolve-theme profile-theme system-theme)) + (resolve-theme profile-theme system-theme))) + +(defn use-initialize + [profile] + (let [color (use-theme profile)] + (mf/with-effect [color] + (set-color-scheme color) (notify-color-scheme-listeners!)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e23f915520..bdc9193088 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -131,6 +131,7 @@ [frontend-tests.ui.inspect-stroke-tokens-test] [frontend-tests.ui.jobs-progress-test] [frontend-tests.ui.layout-container-multiple-test] + [frontend-tests.ui.mcp-authorize-test] [frontend-tests.ui.measurements-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.ui.organization-team-switch-test] @@ -152,6 +153,7 @@ [frontend-tests.util-simple-math-test] [frontend-tests.util-text-clipboard-test] [frontend-tests.util-text-editor-test] + [frontend-tests.util-theme-test] [frontend-tests.util-webapi-test] [frontend-tests.util-zip-test] [frontend-tests.util.code-gen-markup-svg-wasm-test] @@ -301,6 +303,7 @@ 'frontend-tests.ui.measurements-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.organization-team-switch-test + 'frontend-tests.ui.mcp-authorize-test 'frontend-tests.ui.routes-test 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test @@ -321,6 +324,7 @@ 'frontend-tests.util-simple-math-test 'frontend-tests.util-text-clipboard-test 'frontend-tests.util-text-editor-test + 'frontend-tests.util-theme-test 'frontend-tests.util-webapi-test 'frontend-tests.util.code-gen-markup-svg-wasm-test 'frontend-tests.util.dom.dnd-test diff --git a/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs b/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs new file mode 100644 index 0000000000..b91e3cbef2 --- /dev/null +++ b/frontend/test/frontend_tests/ui/mcp_authorize_test.cljs @@ -0,0 +1,88 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.mcp-authorize-test + (:require + [app.common.uri :as uri] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.mcp-authorize :as oauth] + [app.util.http :as http] + [app.util.storage :as storage] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] + [frontend-tests.helpers.mock :as mock])) + +(t/deftest ^:async approval-uses-form-encoding-and-preserves-response-keys + (let [sent (atom nil)] + (await + (mock/with-mocks* + {http/send! (mock/stub + (fn [request] + (reset! sent request) + (->> (rx/of {:status 200 :body #js {"redirect-uri" "https://client.example/cb?code=abc"}}) + (rx/observe-on :async))))} + (let [response (await (a/->promise + (#'oauth/oauth-request :post "decision" + {:request "opaque-handle" :csrf "a+b&c" :decision "approve"})))] + (t/is (= :post (:method @sent))) + (t/is (= "application/x-www-form-urlencoded" (get-in @sent [:headers "content-type"]))) + (t/is (= {:request "opaque-handle" :csrf "a+b&c" :decision "approve"} + (uri/query-string->map (:body @sent)))) + (t/is (= "https://client.example/cb?code=abc" (get-in response [:body :redirect-uri]))) + (t/is (nil? (get-in response [:body :access_token])))))))) + +(t/deftest ^:async approval-request-preserves-login-required-response + (await + (mock/with-mocks* + {http/send! (mock/stub + (fn [_] + (->> (rx/of {:status 401 :body #js {:error "login_required"}}) + (rx/observe-on :async))))} + (let [response (await (a/->promise (#'oauth/oauth-request :get "request" {:request "opaque-handle"})))] + (t/is (= 401 (:status response))) + (t/is (= "login_required" (get-in response [:body :error]))))))) + +(t/deftest ^:async login-keeps-the-approval-return-url-without-navigation + (let [href "https://penpot.example/?screen=mcp-authorize&request=opaque-handle" + saved (:login-redirect storage/session) + emitted (atom [])] + (try + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (fn [] href)) + st/emit! (mock/stub (fn [event] (swap! emitted conj event)))} + (let [params (#'oauth/prepare-login!)] + (await (a/settle)) + (t/is (= href (:callback-url params))) + (t/is (= href (:login-redirect storage/session))) + (t/is (empty? @emitted))))) + (finally + (binding [storage/*sync* true] + (if saved + (swap! storage/session assoc :login-redirect saved) + (swap! storage/session dissoc :login-redirect))))))) + +(t/deftest ^:async cancelling-login-clears-only-this-attempts-return-url + (let [href "https://penpot.example/?screen=mcp-authorize&request=opaque-handle" + saved (:login-redirect storage/session)] + (try + (await + (mock/with-mocks* + {rt/get-current-href (mock/stub (fn [] href))} + (#'oauth/prepare-login!) + (#'oauth/cancel-login!) + (t/is (nil? (:login-redirect storage/session))) + (binding [storage/*sync* true] + (swap! storage/session assoc :login-redirect "https://penpot.example/other")) + (#'oauth/cancel-login!) + (t/is (= "https://penpot.example/other" (:login-redirect storage/session))))) + (finally + (binding [storage/*sync* true] + (if saved + (swap! storage/session assoc :login-redirect saved) + (swap! storage/session dissoc :login-redirect))))))) diff --git a/frontend/test/frontend_tests/util_theme_test.cljs b/frontend/test/frontend_tests/util_theme_test.cljs new file mode 100644 index 0000000000..83889bab96 --- /dev/null +++ b/frontend/test/frontend_tests/util_theme_test.cljs @@ -0,0 +1,23 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.util-theme-test + (:require + [app.util.theme :as theme] + [cljs.test :as t])) + +(t/deftest unset-theme-follows-system + (doseq [system ["light" "dark"]] + (t/is (= system (theme/resolve-theme nil system))) + (t/is (= system (theme/resolve-theme theme/default system))) + (t/is (= system (theme/resolve-theme "system" system))))) + +(t/deftest explicit-theme-overrides-system + (doseq [system ["light" "dark"]] + (t/is (= "light" (theme/resolve-theme "light" system))) + (t/is (= "dark" (theme/resolve-theme "dark" system))) + ;; Older profiles store the dark preference as "default". + (t/is (= "dark" (theme/resolve-theme "default" system))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 09b305cc19..8d5af490a5 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9716,3 +9716,27 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Autosaved versions will be kept for %s days." + +msgid "mcp.oauth.title" +msgstr "Connect to Penpot MCP" + +msgid "mcp.oauth.request" +msgstr "%s wants to connect to Penpot." + +msgid "mcp.oauth.account" +msgstr "You are signed in as %s." + +msgid "mcp.oauth.access" +msgstr "This client can read and change designs through MCP connections established from Penpot." + +msgid "mcp.oauth.enable" +msgstr "Allowing access enables the MCP integration and creates your MCP token if needed." + +msgid "mcp.oauth.approve" +msgstr "Allow access" + +msgid "mcp.oauth.failed" +msgstr "This request has expired, was already used, or could not be completed. Start the connection again from your MCP client." + +msgid "mcp.oauth.cancelled" +msgstr "Connection cancelled" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 94ac75a0a6..0e75450d92 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9820,3 +9820,9 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](% #: src/app/main/ui/workspace/sidebar/versions.cljs:462 msgid "workspace.versions.warning.text" msgstr "Los autoguardados duran %s días." + +msgid "mcp.oauth.access" +msgstr "Este cliente puede leer y modificar diseños mediante conexiones MCP establecidas desde Penpot." + +msgid "mcp.oauth.cancelled" +msgstr "Conexión cancelada" diff --git a/manage.sh b/manage.sh index f9a5824bfe..9ef5fb72ad 100755 --- a/manage.sh +++ b/manage.sh @@ -784,13 +784,16 @@ function start-instance { instance-compose "$instance" up -d main - # Wait briefly for main to be reachable; the tmux session lives inside. + # Running does not mean entrypoint setup has finished. Nginx starts only + # after ownership and Cargo setup, so wait for its local health endpoint + # before launching builds as penpot (especially on a fresh container). local container deadline container=$(devenv-main-container "$instance") - deadline=$(( SECONDS + 30 )) - while ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; do + deadline=$(( SECONDS + 120 )) + while ! docker exec "$container" curl --fail --silent --max-time 2 \ + --output /dev/null http://127.0.0.1:8082/stub_status 2>/dev/null; do [[ $SECONDS -ge $deadline ]] && { - echo "[${instance}] main container did not reach Running within 30s" >&2 + echo "[${instance}] main container initialization did not finish within 120s; check docker logs $container" >&2 return 1 } sleep 1 diff --git a/mcp/README.md b/mcp/README.md index 59d60aaf61..b38b60d502 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -299,6 +299,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | +| `PENPOT_MCP_AUTH_URI` | Public base URL of the MCP authorization service, e.g. `https://penpot.example/api/mcp/oauth`. Set to enable OAuth; requires `--multi-user`. Use HTTPS (HTTP is allowed on loopback for development), without credentials, query parameters, or a fragment. | (unset; OAuth disabled) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | ### Logging Configuration diff --git a/mcp/packages/server/src/McpAuthentication.test.ts b/mcp/packages/server/src/McpAuthentication.test.ts new file mode 100644 index 0000000000..715e60fdc9 --- /dev/null +++ b/mcp/packages/server/src/McpAuthentication.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import express from "express"; +import { McpAuthentication } from "./McpAuthentication"; + +test("OAuth is opt-in and requires multi-user mode", () => { + assert.equal(new McpAuthentication(undefined, false).isOAuthEnabled, false); + assert.equal(new McpAuthentication("", true).isOAuthEnabled, false); + assert.throws(() => new McpAuthentication("https://penpot.example/api/mcp/oauth", false), /multi-user/); + for (const uri of [ + "not a URL", + "http://remote.example/auth", + "https://user:secret@example.com/auth", + "https://example.com/auth?x=1", + "https://example.com/auth#fragment", + ]) { + assert.throws(() => new McpAuthentication(uri, true), /PENPOT_MCP_AUTH_URI/); + } + assert.equal(new McpAuthentication("http://localhost:3449/api/mcp/oauth", true).isOAuthEnabled, true); +}); + +async function withEndpoint(uri: string | undefined, run: (base: string) => Promise) { + const auth = new McpAuthentication(uri, true); + const app = express(); + app.all("/mcp", (req, res) => { + const result = auth.authenticate(req, res); + if (!result.isRejected) res.json({ token: result.userToken ?? null }); + }); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + try { + await run(`http://127.0.0.1:${address.port}/mcp`); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +} + +test("missing credentials challenge the client only when OAuth is enabled", async () => { + await withEndpoint(undefined, async (base) => { + const response = await fetch(base); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { token: null }); + }); + await withEndpoint("https://penpot.example/api/mcp/oauth/", async (base) => { + const response = await fetch(base); + assert.equal(response.status, 401); + assert.equal( + response.headers.get("www-authenticate"), + 'Bearer resource_metadata="https://penpot.example/api/mcp/oauth/resource", scope="mcp"' + ); + assert.equal(response.headers.get("cache-control"), "no-store"); + }); +}); + +test("bearer credentials take precedence and query credentials remain supported", async () => { + for (const uri of [undefined, "https://penpot.example/api/mcp/oauth"]) { + await withEndpoint(uri, async (base) => { + const bearer = await fetch(base + "?userToken=query-token", { + headers: { Authorization: "bEaReR header-token" }, + }); + assert.deepEqual(await bearer.json(), { token: "header-token" }); + const query = await fetch(base + "?userToken=query-token"); + assert.deepEqual(await query.json(), { token: "query-token" }); + }); + } +}); + +test("malformed credentials never fall back to another token", async () => { + await withEndpoint("https://penpot.example/api/mcp/oauth", async (base) => { + for (const header of ["Basic abc", "Bearer", "Bearer two tokens", "Bearer a, Bearer b"]) { + const response = await fetch(base + "?userToken=valid", { headers: { Authorization: header } }); + assert.equal(response.status, 401, header); + assert.deepEqual(await response.json(), { error: "invalid_token" }); + } + for (const query of ["?userToken=a&userToken=b", "?userToken=", "?userToken=a%20b"]) { + assert.equal((await fetch(base + query)).status, 401, query); + } + }); +}); diff --git a/mcp/packages/server/src/McpAuthentication.ts b/mcp/packages/server/src/McpAuthentication.ts new file mode 100644 index 0000000000..6df14f6733 --- /dev/null +++ b/mcp/packages/server/src/McpAuthentication.ts @@ -0,0 +1,120 @@ +import type { Request, Response } from "express"; + +/** + * Result of processing credentials from an MCP HTTP request. + */ +export class McpAuthenticationResult { + /** + * @param userToken the extracted user token, if present + * @param isRejected whether the request was rejected and its HTTP error response sent + */ + constructor( + public readonly userToken: string | undefined, + public readonly isRejected: boolean = false + ) {} +} + +/** + * HTTP credential extraction and optional OAuth discovery for MCP clients. + * + * Tokens retain their existing role of matching requests to Penpot connections. + */ +export class McpAuthentication { + public readonly isOAuthEnabled: boolean; + private readonly resourceMetadataUri?: string; + + /** + * Creates a new MCP authentication handler. + * + * @param authUri the authentication URI from PENPOT_MCP_AUTH_URI, or undefined/empty to disable OAuth + * @param multiUser whether the server is running in multi-user mode + */ + constructor(authUri: string | undefined, multiUser: boolean) { + if (!authUri?.trim()) { + this.isOAuthEnabled = false; + } else { + if (!multiUser) { + throw new Error("PENPOT_MCP_AUTH_URI (which enables OAuth) requires multi-user mode"); + } + + // validate the authentication URI + let uri: URL; + try { + uri = new URL(authUri); + } catch { + throw new Error("PENPOT_MCP_AUTH_URI must be an absolute HTTP(S) URL"); + } + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(uri.hostname); + if ( + (uri.protocol !== "https:" && !(uri.protocol === "http:" && loopback)) || + uri.username || + uri.password || + uri.search || + uri.hash + ) { + throw new Error( + "PENPOT_MCP_AUTH_URI must use HTTPS (HTTP is allowed on loopback) without credentials, query, or fragment" + ); + } + + this.resourceMetadataUri = uri.href.replace(/\/$/, "") + "/resource"; + this.isOAuthEnabled = true; + } + } + + /** + * Extracts credentials. + * If credentials are absent while OAuth is enabled, sends an authentication error. + */ + public authenticate(req: Request, res: Response): McpAuthenticationResult { + let userToken: string | undefined; + + // check for a Bearer token in the Authorization header + const header = req.headers.authorization; + if (header !== undefined) { + const headerCount = req.rawHeaders.filter( + (_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === "authorization" + ).length; + const match = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i.exec(header); + if (headerCount !== 1 || !match) { + this.rejectOAuth(res, "invalid_token"); + return new McpAuthenticationResult(undefined, true); + } + userToken = match[1]; + } + // check for a userToken query parameter + else if (req.query.userToken !== undefined) { + const value = req.query.userToken; + if (typeof value !== "string" || !/^[A-Za-z0-9\-._~+/]+=*$/.test(value)) { + this.rejectOAuth(res, "invalid_token"); + return new McpAuthenticationResult(undefined, true); + } + userToken = value; + } + + // NOTE: The MCP server does not validate userToken against the backend. + // It uses the token to match MCP requests to a connection established + // from the Penpot webapp. Without that matching connection, requests + // cannot access or modify designs. The user must be logged in to Penpot + // to establish that connection; Penpot login and file permissions thus + // provide the authentication and access control boundary. + + if (this.isOAuthEnabled && !userToken) { + this.rejectOAuth(res); + return new McpAuthenticationResult(undefined, true); + } else { + return new McpAuthenticationResult(userToken); + } + } + + private rejectOAuth(res: Response, error?: "invalid_token"): void { + let challenge = "Bearer"; + if (this.resourceMetadataUri) { + challenge += ` resource_metadata="${this.resourceMetadataUri}", scope="mcp"`; + } + if (error) challenge += `${this.resourceMetadataUri ? "," : ""} error="${error}"`; + res.setHeader("WWW-Authenticate", challenge); + res.setHeader("Cache-Control", "no-store"); + res.status(401).json({ error: error ?? "unauthorized" }); + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.http.test.ts b/mcp/packages/server/src/PenpotMcpServer.http.test.ts index 2a72ed5305..693e3197ab 100644 --- a/mcp/packages/server/src/PenpotMcpServer.http.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.http.test.ts @@ -16,6 +16,7 @@ beforeEach(async () => { process.env.PENPOT_MCP_DEVENV = "false"; process.env.PENPOT_MCP_REPL_ENABLE = "false"; delete process.env.PENPOT_MCP_REDIS_URI; + delete process.env.PENPOT_MCP_AUTH_URI; server = new PenpotMcpServer(true); baseUrl = `http://127.0.0.1:${server.port}`; await server.start(); @@ -26,7 +27,7 @@ afterEach(async () => { process.env = previousEnv; }); -async function modernRequest(method: string, params: Record = {}, query = "") { +async function modernRequest(method: string, params: Record = {}, query = "", bearer?: string) { return fetch(`${baseUrl}/mcp${query}`, { method: "POST", headers: { @@ -34,6 +35,7 @@ async function modernRequest(method: string, params: Record = { Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method, + ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(typeof params.name === "string" ? { "Mcp-Name": params.name } : {}), }, body: JSON.stringify({ @@ -93,7 +95,8 @@ test("isolates user tokens across overlapping tool calls with the same request I name: "execute_code", arguments: { code: "return 1;" }, }, - `?userToken=${token}` + token === "alice" ? "" : `?userToken=${token}`, + token === "alice" ? token : undefined ); const body = (await response.json()) as { result: CallToolResult }; assert.equal(response.status, 200, JSON.stringify(body)); @@ -135,3 +138,30 @@ test("supports older Streamable HTTP clients without allocating a session", asyn await client.close(); } }); + +test("OAuth challenges missing credentials and accepts bearer and query tokens", async () => { + await server.stop(); + process.env.PENPOT_MCP_AUTH_URI = "https://penpot.example/api/mcp/oauth"; + server = new PenpotMcpServer(true); + await server.start(); + + const missing = await modernRequest("tools/list"); + assert.equal(missing.status, 401); + assert.match(missing.headers.get("www-authenticate") ?? "", /resource_metadata=/); + + for (const useBearer of [true, false]) { + const client = new Client({ name: "oauth-test", version: "1" }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp${useBearer ? "" : "?userToken=alice"}`), + useBearer ? { requestInit: { headers: { Authorization: "Bearer alice" } } } : undefined + ); + try { + await client.connect(transport); + const result = await client.listTools(); + const tool = result.tools.find((tool) => tool.name === "execute_code"); + assert.ok(tool, "authenticated clients can discover execute_code"); + } finally { + await client.close(); + } + } +}); diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 73ffd83a9f..2ec5cbe146 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -22,6 +22,7 @@ import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; +import { McpAuthentication } from "./McpAuthentication"; /** * Session context for request-scoped data. @@ -111,6 +112,7 @@ export class PenpotMcpServer { private app!: Express; private httpServer?: HttpServer; private readonly mcpHandler: McpHttpHandler; + private readonly authentication: McpAuthentication; public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer | null; private apiDocs: ApiDocs; @@ -144,6 +146,7 @@ export class PenpotMcpServer { private readonly tenant: string; constructor(private isMultiUser: boolean = false) { + this.authentication = new McpAuthentication(process.env.PENPOT_MCP_AUTH_URI, isMultiUser); // read port configuration from environment variables this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10); @@ -298,11 +301,16 @@ export class PenpotMcpServer { private setupHttpEndpoints(): void { const handleMcpRequest = toNodeHandler(this.mcpHandler); this.app.all("/mcp", async (req, res) => { - const userToken = req.query.userToken as string | undefined; + const authResult = this.authentication.authenticate(req, res); + if (authResult.isRejected) { + return; + } this.logger.info( - `Received MCP request: method=${req.body?.method ?? ""}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + `Received MCP request: method=${req.body?.method ?? ""}; userTokenFp=${PenpotMcpServer.tokenFingerprint(authResult.userToken)}` + ); + await this.sessionContext.run({ userToken: authResult.userToken }, () => + handleMcpRequest(req, res, req.body) ); - await this.sessionContext.run({ userToken }, () => handleMcpRequest(req, res, req.body)); }); } @@ -316,6 +324,7 @@ export class PenpotMcpServer { return new Promise((resolve, reject) => { this.httpServer = this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); + this.logger.info(`OAuth enabled: ${this.authentication.isOAuthEnabled}`); this.logger.info( `Multi-instance mode with Redis-backed transport: ${this.redisBridge ? "true" : "false"}` ); diff --git a/mcp/scripts/start-mcp-devenv-oauth b/mcp/scripts/start-mcp-devenv-oauth new file mode 100755 index 0000000000..230dbf4029 --- /dev/null +++ b/mcp/scripts/start-mcp-devenv-oauth @@ -0,0 +1,19 @@ +#!/bin/sh + +# This starts the MCP server in multi-user mode with OAuth enabled, for +# testing OAuth in the Penpot development environment (assuming devenv). +# Agentic tooling expects the single-user server from start-mcp-devenv. + +set -e + +if [ -z "$PENPOT_PUBLIC_URI" ]; then + echo "PENPOT_PUBLIC_URI is not set; run this inside the devenv container." >&2 + exit 1 +fi + +PENPOT_MCP_AUTH_URI="${PENPOT_PUBLIC_URI%/}/api/mcp/oauth" + +echo "OAuth authorization URI: $PENPOT_MCP_AUTH_URI" +echo "MCP endpoint for clients: ${PENPOT_PUBLIC_URI%/}/mcp/stream" + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_AUTH_URI="$PENPOT_MCP_AUTH_URI" pnpm run bootstrap:multi-user From c0f0b14540983d43724004d86a64c8befb94cf2b Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Thu, 29 Oct 2026 10:35:46 +0100 Subject: [PATCH 188/325] :tada: Add direct MCP feedback channel via dedicated tool (#12551) * :recycle: Move devenv-only tools to a separate folder * :tada: Add an MCP feedback channel Add a user-approved feedback tool and an MCP-token-authenticated backend endpoint that queues plain-text email. Enable the tool through the configured backend URI in both local and multi-user mode. Validate subject and feedback limits, enforce per-profile rate limits, and document configuration. Cover authentication, queueing, tool availability, and failure handling with backend and MCP tests. Closes #12490 AI-assisted-by: gpt-6 * :recycle: Move MCP feedback submission into RPC Use the standard RPC wrappers for feedback and remove the dedicated HTTP endpoint, route wiring, and custom rate limiter. Restrict MCP tokens to opted-in routes and commands. Add a shared RPC call helper to the MCP backend client and adapt the existing tests. AI-assisted-by: gpt-6 * :bug: Harden MCP feedback auth, validation and messages Accept MCP tokens when either the mcp or the access-tokens flag is on, so instances that enable MCP without personal access tokens can send feedback. Personal tokens still need access-tokens. Keep the issuer in one constant, decode tokens without raising to reject unknown issuers, and name the issuer and type checks. Reject control characters in the subject, and in the feedback all but tabs and line breaks, in both the command and the tool, so a NUL answers 400 instead of failing when the mail job is stored. Answer 403 with profile-blocked or profile-not-active for unusable profiles, record only who sent feedback in the audit event, and add doc metadata. Replace on OAuth approval an MCP token issued before MCP tokens had their own issuer, or one this instance cannot decrypt, since the backend rejects both. In the MCP server, mention disabled access tokens on 401, drop the unused 503 case, and say that delivery happens in the background. Document the flags in the README. AI-assisted-by: claude-opus-5-5 * :books: Document the MCP feedback tool and MCP token rules List submit_feedback among the MCP tools and add an MCP feedback section to the configuration guide covering PENPOT_MCP_BACKEND_URI, PENPOT_MCP_FEEDBACK_EMAIL, the flags and the email settings. Add commented examples of both variables to the compose file. In the agent memories, record when OAuth approval replaces an MCP token, how the MCP server reaches the backend, and which flags, routes and commands accept MCP tokens. AI-assisted-by: claude-opus-5-5 --- .../auth-permissions-product-domains.md | 3 + .serena/memories/backend/core.md | 2 +- .serena/memories/mcp/core.md | 9 + .serena/memories/mcp/oauth.md | 4 +- backend/src/app/config.clj | 1 + backend/src/app/http/access_token.clj | 74 +++--- backend/src/app/rpc.clj | 18 +- backend/src/app/rpc/commands/access_token.clj | 14 +- backend/src/app/rpc/commands/feedback.clj | 48 +++- .../test/backend_tests/http_assets_test.clj | 8 +- .../backend_tests/http_middleware_test.clj | 40 +++- .../backend_tests/rpc_access_tokens_test.clj | 39 +++- .../rpc_commands_mcp_feedback_test.clj | 179 ++++++++++++++ backend/test/backend_tests/rpc_test.clj | 25 +- docker/images/docker-compose.yaml | 9 + docs/mcp/index.md | 3 + docs/technical-guide/configuration.md | 24 ++ mcp/README.md | 13 +- mcp/packages/server/src/Feedback.test.ts | 221 ++++++++++++++++++ .../server/src/PenpotBackendClient.ts | 86 +++++++ mcp/packages/server/src/PenpotMcpServer.ts | 16 +- .../server/src/tools/SubmitFeedbackTool.ts | 67 ++++++ .../tools/{ => dev}/CljCheckParentheses.ts | 8 +- .../tools/{ => dev}/CljsCompilerOutputTool.ts | 10 +- .../src/tools/{ => dev}/CljsReplTool.ts | 10 +- .../tools/{ => dev}/ImportPenpotFileTool.ts | 10 +- .../src/tools/{ => dev}/ReadTaigaIssueTool.ts | 8 +- 27 files changed, 871 insertions(+), 78 deletions(-) create mode 100644 backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj create mode 100644 mcp/packages/server/src/Feedback.test.ts create mode 100644 mcp/packages/server/src/PenpotBackendClient.ts create mode 100644 mcp/packages/server/src/tools/SubmitFeedbackTool.ts rename mcp/packages/server/src/tools/{ => dev}/CljCheckParentheses.ts (97%) rename mcp/packages/server/src/tools/{ => dev}/CljsCompilerOutputTool.ts (86%) rename mcp/packages/server/src/tools/{ => dev}/CljsReplTool.ts (89%) rename mcp/packages/server/src/tools/{ => dev}/ImportPenpotFileTool.ts (98%) rename mcp/packages/server/src/tools/{ => dev}/ReadTaigaIssueTool.ts (96%) diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index 2a58e49dfd..d9ee64c171 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -13,6 +13,9 @@ - Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. - When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile. +- Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous. +- Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous. +- On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves. - HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 2d44a88b76..7a70eb4669 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -32,7 +32,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m RPC commands are defined with `app.util.services/defmethod` and schemas. Use `get-` prefixes for read operations. Command metadata usually includes auth, docs version, params schema, and result schema. Return plain maps/vectors or raise structured exceptions from `app.common.exceptions`. -Backend RPC command areas without focused memories include access tokens, binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. +Backend RPC command areas without focused memories include access tokens (auth rules, incl. MCP tokens, in `mem:backend/auth-permissions-product-domains`), binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them. ## DB conventions diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 551ef961c6..dbc4b85b92 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -113,6 +113,15 @@ From the `mcp/` directory, run - Authorization/discovery URLs, self-contained client IDs, consent and PKCE/code binding, backend Redis state, deployment wiring, and compatibility limits: `mem:mcp/oauth`. +## Backend tools (`submit_feedback`) + +- Tools that call the Penpot backend directly (not through the plugin) register only when `PENPOT_MCP_BACKEND_URI` is nonblank (`PenpotMcpServer.initTools`). Currently only `SubmitFeedbackTool`. +- `PenpotBackendClient` owns the HTTP call and the status-to-message mapping; it posts to `api/main/methods/send-mcp-feedback` with `Authorization: Token `. The token comes from the session context (OAuth bearer or `?userToken=`); without one the tool fails before calling the backend. +- Tool zod schemas mirror the backend malli schema (lengths, blank, control characters); keep both in sync. The backend is the authority. +- Success means the mail job was queued, not delivered; the tool text says so. `formatArgs` hides the feedback from logs. +- Backend side: `send-mcp-feedback` in `app.rpc.commands.feedback`, configured by `PENPOT_MCP_FEEDBACK_EMAIL`; MCP-token auth rules in `mem:backend/auth-permissions-product-domains`. +- User docs: `mcp/README.md` (env tables), `docs/mcp/index.md` (tool list), `docs/technical-guide/configuration.md` (MCP feedback section). + ## Devenv plugin/server wiring In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter. diff --git a/.serena/memories/mcp/oauth.md b/.serena/memories/mcp/oauth.md index 097bdc1186..110372cb4c 100644 --- a/.serena/memories/mcp/oauth.md +++ b/.serena/memories/mcp/oauth.md @@ -3,7 +3,7 @@ ## Contract and scope - MCP-specific authorization; existing Penpot login identifies the user on the approval page. No application vetting or persistent consent policy. Access requires user approval; registration alone grants nothing. -- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no unexpired one exists; preserve an existing unexpired token. Settings can give MCP tokens an expiry, and an expired one is replaced. No refresh token, `expires_in`, or new token lifetime. +- Return the existing fixed MCP user token. On approval, enable `:mcp-enabled` and create the token only if no usable one exists; preserve an existing usable token. A token is unusable, and replaced, when expired (settings can give MCP tokens an expiry), when its JWT issuer is not `urn:penpot:mcp-token` (tokens issued before MCP tokens had their own issuer), or when this instance cannot decrypt it (e.g. secret key changed); the backend rejects all three. No refresh token, `expires_in`, or new token lifetime. - No new client database table or schema migration. Client IDs must remain usable across separate authorization flows without server-side registration storage. Do not replace them with expiring Redis registrations. - MCP transport continues matching user tokens to connected Penpot plugins; no new per-request token introspection or independent semantic validation. The logged-in Penpot instance remains the content-access boundary. Do not silently add resource-server validation or change token semantics. - README scope: user/developer usage instructions; document `PENPOT_MCP_AUTH_URI` in its environment-variable table. Keep OAuth architecture and protocol details here and non-obvious invariants in source comments. @@ -38,7 +38,7 @@ - `/decision`: check exact Origin, session binding and CSRF, then atomically consume the request. Denial creates no token/enables nothing. Approval calls transactional `access-token/ensure-mcp-access!`, then stores code for 120 seconds. Code state holds token row ID, never the fixed token. Callback echoes OAuth `state` and RFC 9207 `iss`. - `/token`: validate grant, exact client ID/callback/resource and PKCE; resolve current client metadata/authentication method and verify any secret; atomically consume validated code; fetch the unexpired token by original row ID/profile/type. Regeneration deletes the old token row, so a pending code cannot silently yield the replacement token. Return fixed `access_token`, `token_type: Bearer`, scope `mcp`. - `backend/src/app/mcp/oauth_state.clj` and Lua: tenant/kind/hash-prefixed keys; random handles; SET NX EX must succeed before returning handle. Reads retain raw JSON for atomic comparison. Consume/delete and bind/replace compare the validated raw record in Lua; replacement preserves TTL. Redis failures fail closed. Backend Redis is required independently of Node's optional Redis plugin bridge. -- `backend/src/app/rpc/commands/access_token.clj`: lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. +- `backend/src/app/rpc/commands/access_token.clj`: `ensure-mcp-access!` decodes the stored token with `ex/ignoring` to apply the usable-token rule above. Lock profile row in both MCP generation and OAuth provisioning, including when no token exists. Serializes simultaneous approvals and settings regeneration. Provisioning must run within a transaction. ## Verification and limits diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 33fde1fd5f..515f3bec49 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -206,6 +206,7 @@ [:allow-demo-users {:optional true} ::sm/boolean] [:error-report-webhook {:optional true} :string] [:user-feedback-destination {:optional true} :string] + [:mcp-feedback-email {:optional true} ::sm/email] [:default-rpc-rlimit {:optional true} [::sm/vec :string]] [:rpc-rlimit-config {:optional true} ::fs/path] diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj index 57f19fdda8..017b042bd4 100644 --- a/backend/src/app/http/access_token.clj +++ b/backend/src/app/http/access_token.clj @@ -14,14 +14,20 @@ [app.setup :as-alias setup] [app.tokens :as tokens])) +(def mcp-token-issuer "urn:penpot:mcp-token") + +(def ^:private valid-issuers + #{"access-token" mcp-token-issuer}) + (defn decode-token [cfg token] (try - (tokens/verify cfg {:token token :iss "access-token"}) - (catch Throwable cause - (l/trc :hint "exception on decoding token" - :token token - :cause cause)))) + (let [claims (tokens/verify cfg {:token token})] + (when (contains? valid-issuers (:iss claims)) + claims)) + ;; The cause is not logged because its data carries the token + (catch Throwable _ + (l/trc :hint "exception on decoding token")))) (def sql:get-token-data "SELECT perms, profile_id, expires_at, type @@ -34,32 +40,48 @@ [pool claims] (when-not (db/read-only? pool) (when-let [token-id (get claims :tid)] - (some-> (db/exec-one! pool [sql:get-token-data token-id]) - (update :perms db/decode-pgarray #{}))))) + (let [token (db/exec-one! pool [sql:get-token-data token-id]) + mcp-claims? (= mcp-token-issuer (:iss claims)) + mcp-token? (= "mcp" (:type token))] + (when (and (= (:uid claims) (:profile-id token)) + (= mcp-claims? mcp-token?)) + (update token :perms db/decode-pgarray #{})))))) (defn- wrap-authz - [handler {:keys [::db/pool]}] - (fn [request] - (let [{:keys [type claims]} (get request ::http/auth-data)] - (if (= :token type) - (let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool)) - token-id (get claims :tid)] - (handler (cond-> request - (some? perms) - (assoc ::perms perms) - (some? profile-id) - (assoc ::profile-id profile-id) - (some? expires-at) - (assoc ::expires-at expires-at) - (some? token-id) - (assoc ::id token-id) - (some? type) - (assoc ::type type)))) + "Authenticate requests carrying an access token. Personal tokens need the + `access-tokens` flag. MCP tokens are restricted to specific commands, so + only RPC routes opt in (the RPC wrapper enforces those restrictions); they + need either the `mcp` or the `access-tokens` flag." + [handler {:keys [::db/pool ::allow-mcp]}] + (let [personal-enabled? (contains? cf/flags :access-tokens) + mcp-enabled? (and allow-mcp + (or personal-enabled? + (contains? cf/flags :mcp)))] + (fn [request] + (let [{:keys [type claims]} (get request ::http/auth-data)] + (if (and (= :token type) + (if (= mcp-token-issuer (:iss claims)) + mcp-enabled? + personal-enabled?)) + (let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool)) + token-id (get claims :tid)] + (handler (cond-> request + (some? perms) + (assoc ::perms perms) + (some? profile-id) + (assoc ::profile-id profile-id) + (some? expires-at) + (assoc ::expires-at expires-at) + (some? token-id) + (assoc ::id token-id) + (some? type) + (assoc ::type type)))) - (handler request))))) + (handler request)))))) (def authz {:name ::authz :compile (fn [& _] - (when (contains? cf/flags :access-tokens) + (when (or (contains? cf/flags :access-tokens) + (contains? cf/flags :mcp)) wrap-authz))}) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 7ca7816cfc..8d922822c7 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -124,7 +124,8 @@ (cond-> (and (not (uuid? session-pid)) (uuid? token-pid)) (-> (assoc ::auth-type :token) - (assoc ::token-perms (set (::actoken/perms request #{}))))) + (assoc ::token-perms (set (::actoken/perms request #{}))) + (assoc ::token-type (::actoken/type request)))) (cond-> key-id (assoc ::auth-key-id key-id)) ;; Server-side only: request bodies cannot forge @@ -171,10 +172,12 @@ [_ f mdata] (let [required-auth? (::auth mdata true) required-auth-type (::auth-type mdata) + required-token-type (::token-type mdata) required-perms (into #{} (::perms mdata))] (fn [cfg params] (let [profile-id (::profile-id params) auth-type (::auth-type params) + token-type (::token-type params) token-perms (set (::token-perms params #{}))] (cond (and required-auth? (not (uuid? profile-id))) @@ -182,8 +185,13 @@ :code :authentication-required :hint "authentication required for this endpoint") - (and (= required-auth-type :token) - (not= auth-type :token)) + (or (and (= required-auth-type :token) + (not= auth-type :token)) + (and required-token-type + (or (not= auth-type :token) + (not= required-token-type token-type))) + (and (= token-type "mcp") + (not= required-token-type "mcp"))) (ex/raise :type :authorization :code :token-auth-required :hint "access token authentication required for this endpoint") @@ -522,7 +530,7 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz (assoc cfg ::actoken/allow-mcp true)]] :handler (make-rpc-handler methods)}] (doc/routes :methods methods @@ -540,5 +548,5 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz (assoc cfg ::actoken/allow-mcp true)]] :handler (make-rpc-handler methods)}]]) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 6681af3cd7..8474d2ba80 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -6,10 +6,12 @@ (ns app.rpc.commands.access-token (:require + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] + [app.http.access-token :as actoken] [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] @@ -49,7 +51,7 @@ (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) - token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token") + token-iss (if (= type "mcp") actoken/mcp-token-issuer "access-token") token (tokens/generate cfg {:iss token-iss :uid profile-id :iat created-at @@ -79,10 +81,16 @@ Must run in a transaction. Lock the profile even when no token exists, so concurrent approvals and settings regeneration use the same lock. Enabling MCP must not regenerate a token already held by a connected client; an - expired token is replaced because no client can use it." + expired token is replaced because no client can use it, and so is a token + issued before MCP tokens had their own issuer, or one this instance cannot + decrypt, because the backend rejects both." [{:keys [::db/conn] :as cfg} profile-id] (let [profile (profile/get-profile conn profile-id ::db/for-update true) - token (db/exec-one! conn [sql:get-valid-mcp-token profile-id])] + token (db/exec-one! conn [sql:get-valid-mcp-token profile-id]) + ;; A token this instance cannot decrypt is as unusable as an expired one + token (when (= actoken/mcp-token-issuer + (ex/ignoring (:iss (tokens/decode cfg (:token token))))) + token)] (when-not (:mcp-enabled (:props profile)) (profile/update-profile-props cfg profile-id {:mcp-enabled true})) (if token diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 7ff4076da7..69354345a1 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -13,11 +13,57 @@ [app.config :as cf] [app.db :as db] [app.email :as eml] + [app.loggers.audit :as-alias audit] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] - [app.util.services :as sv])) + [app.rpc.helpers :as rph] + [app.util.services :as sv] + [clojure.string :as str])) + +(def schema:send-mcp-feedback + [:map {:title "send-mcp-feedback" :closed true} + [:subject [:and [:string {:min 1 :max 120}] + [:fn #(and (not (str/blank? %)) + (not (re-find #"\p{Cc}" %)))]]] + ;; Line breaks and tabs are the only control characters allowed + [:feedback [:and [:string {:min 1 :max 5000}] + [:fn #(and (not (str/blank? %)) + (not (re-find #"[\p{Cc}&&[^\t\n\r]]" %)))]]]]) + +(sv/defmethod ::send-mcp-feedback + {::rpc/token-type "mcp" + ::db/transaction true + ::doc/added "2.20" + ::sm/params schema:send-mcp-feedback} + [cfg {:keys [::rpc/profile-id subject feedback]}] + (let [profile (profile/get-profile cfg profile-id) + destination (cf/get :mcp-feedback-email)] + ;; Blocking a profile invalidates its sessions but not its access tokens + (when (:is-blocked profile) + (ex/raise :type :authorization + :code :profile-blocked + :hint "profile is marked as blocked")) + (when-not (:is-active profile) + (ex/raise :type :authorization + :code :profile-not-active + :hint "profile is not active")) + (when (str/blank? destination) + (ex/raise :type :restriction + :code :feedback-disabled + :hint "MCP feedback is not configured")) + (eml/send cfg {::eml/reuse-conn true + :to destination + :reply-to (:email profile) + :subject (str "[MCP feedback] " subject) + :body {"text/plain" (str "Submitted by: " (:fullname profile) + " <" (:email profile) ">\n" + "Profile ID: " (:id profile) "\n\n" + "Feedback:\n\n" feedback)}}) + ;; The audit event keeps who sent feedback, not its text + (rph/with-meta {:status "queued"} + {::audit/replace-props {}}))) (declare ^:private send-user-feedback!) diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 1f6c473a51..c9503f1cca 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -31,7 +31,9 @@ io.prometheus.client.Counter io.prometheus.client.Counter$Child)) -(t/use-fixtures :once th/state-init) +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens]) + th/init-system)) (t/use-fixtures :each (th/serial th/database-reset th/clean-storage)) @@ -464,7 +466,7 @@ ;; 2. actoken/authz reads ::http/auth-data and sets ::actoken/profile-id request {::http/auth-data {:type :token :token (:token atoken) - :claims {:tid (:id atoken)}} + :claims (actoken/decode-token th/*system* (:token atoken))} :path-params {:id (str (:id object))}} ;; Apply actoken/authz middleware @@ -517,7 +519,7 @@ (t/testing "access token auth sets ::actoken/profile-id" (let [request {::http/auth-data {:type :token :token (:token atoken) - :claims {:tid (:id atoken)}}} + :claims (actoken/decode-token th/*system* (:token atoken))}} result (with-session request)] (t/is (= (:id profile) (::actoken/profile-id result))))) diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index d2c28857e9..57766ab3b7 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -34,7 +34,9 @@ io.prometheus.client.CollectorRegistry io.undertow.server.RequestTooBigException)) -(t/use-fixtures :once th/state-init) +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens]) + th/init-system)) (t/use-fixtures :each th/database-reset) (t/deftest auth-middleware-1 @@ -106,7 +108,8 @@ (let [response (handler nil)] (t/is (nil? response))) - (let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})] + (let [response (handler {::http/auth-data {:type :token :token (:token token) + :claims (tokens/verify th/*system* {:token (:token token)})}})] (t/is (= #{} (:app.http.access-token/perms response))) (t/is (= (:id profile) (:app.http.access-token/profile-id response)))))) @@ -114,9 +117,14 @@ (let [profile (th/create-profile* 1) token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil "mcp") - handler (#'app.http.access-token/wrap-authz identity th/*system*) - request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}} + handler (#'app.http.access-token/wrap-authz + identity (assoc th/*system* :app.http.access-token/allow-mcp true)) + request {::http/auth-data {:type :token :token (:token token) + :claims (tokens/verify th/*system* {:token (:token token)})}} response (handler request)] + (t/is (nil? (:app.http.access-token/profile-id + ((#'app.http.access-token/wrap-authz identity th/*system*) request))) + "MCP tokens do not authenticate routes that have not opted in") ;; Must set ::actoken/id from claims :tid (t/is (= (:id token) (:app.http.access-token/id response))) ;; Must set ::actoken/type from database @@ -125,6 +133,30 @@ (t/is (= #{} (:app.http.access-token/perms response))) (t/is (= (:id profile) (:app.http.access-token/profile-id response))))) +(t/deftest access-token-authz-follows-flags + (let [profile (th/create-profile* 1) + create #(db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token + (:id profile) "test" nil %) + request (fn [{:keys [token]}] + {::http/auth-data {:type :token :token token + :claims (tokens/verify th/*system* {:token token})}}) + auth (fn [flags token] + (with-redefs [cf/flags flags] + (let [handler (#'app.http.access-token/wrap-authz + identity (assoc th/*system* :app.http.access-token/allow-mcp true))] + (:app.http.access-token/profile-id (handler (request token)))))) + personal (create nil) + mcp (create "mcp")] + (t/testing "only mcp: MCP tokens authenticate, personal tokens do not" + (t/is (= (:id profile) (auth #{:mcp} mcp))) + (t/is (nil? (auth #{:mcp} personal)))) + (t/testing "only access-tokens: both authenticate" + (t/is (= (:id profile) (auth #{:access-tokens} mcp))) + (t/is (= (:id profile) (auth #{:access-tokens} personal)))) + (t/testing "neither flag: the middleware is not installed" + (with-redefs [cf/flags #{}] + (t/is (nil? ((:compile app.http.access-token/authz)))))))) + (defrecord MethodAwareDummyRequest [req-method headers] yreq/IRequest (method [_] req-method) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index e5fcaf5348..359bbf66fd 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.rpc-access-tokens-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] [app.http :as http] @@ -13,6 +14,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.access-token :as access-token] [app.storage :as sto] + [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.test :as t] [mockery.core :refer [with-mocks]])) @@ -208,7 +210,7 @@ (t/is (not (contains? all-ids (:id second-mcp)))) (t/is (contains? all-ids (:id third-mcp)))))))) -(t/deftest mcp-tokens-cannot-be-used-as-access-tokens +(t/deftest access-token-decoder-accepts-mcp-and-personal-issuers (let [prof (th/create-profile* 1 {:is-active true}) cfg th/*system*] @@ -219,9 +221,9 @@ :name "mcp token"}) mcp-token (:token result) - ;; Try to decode as access token (should fail) decoded (actoken/decode-token cfg mcp-token)] - (t/is (nil? decoded)))) + (t/is (= "urn:penpot:mcp-token" (:iss decoded))) + (t/is (= (:id prof) (:uid decoded))))) (t/testing "Regular access tokens use access-token issuer claim" (let [{:keys [result]} (th/command! {::th/type :create-access-token @@ -264,6 +266,37 @@ (t/is (nil? (:expires-at token))) (t/is (= [(:id token)] (map :id (db/query th/*pool* :access-token {:profile-id (:id prof) :type "mcp"})))))) + (t/testing "approval replaces a token issued with the personal-token issuer" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [token-id (uuid/next) + now (ct/now) + old (tokens/generate cfg {:iss "access-token" :uid (:id prof) + :iat now :tid token-id})] + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :access-token + {:id token-id :name "MCP" :token old :type "mcp" + :profile-id (:id prof) :created-at now :updated-at now + :perms (db/create-array conn "text" [])}))) + (let [token (provision)] + (t/is (not= old (:token token))) + (t/is (= actoken/mcp-token-issuer + (:iss (actoken/decode-token cfg (:token token))))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"}))))))) + (t/testing "approval replaces a token this instance cannot decrypt" + (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) + (let [now (ct/now)] + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :access-token + {:id (uuid/next) :name "MCP" :token "not-a-jwe" :type "mcp" + :profile-id (:id prof) :created-at now :updated-at now + :perms (db/create-array conn "text" [])}))) + (let [token (provision)] + (t/is (not= "not-a-jwe" (:token token))) + (t/is (= [(:id token)] + (map :id (db/query th/*pool* :access-token + {:profile-id (:id prof) :type "mcp"}))))))) (t/testing "concurrent approvals share one token" (db/delete! th/*pool* :access-token {:profile-id (:id prof)}) (let [results (mapv deref (repeatedly 4 #(future (provision))))] diff --git a/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj b/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj new file mode 100644 index 0000000000..7987596481 --- /dev/null +++ b/backend/test/backend_tests/rpc_commands_mcp_feedback_test.clj @@ -0,0 +1,179 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-commands-mcp-feedback-test + (:require + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.email :as email] + [app.http :as http] + [app.jobs :as jobs] + [app.rpc :as-alias rpc] + [app.rpc.commands.access-token :as access-token] + [app.tokens :as tokens] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.response :as-alias yres])) + +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-access-tokens + :enable-client-header-check-middleware]) + th/init-system)) +(t/use-fixtures :each th/database-reset) + +(def ^:private feedback + {:subject "Export fails" + :feedback "Exporting a valid shape repeatedly fails. Expected a PNG."}) + +(defn- create-identity + [] + (let [profile (th/create-profile* (uuid/next) {:is-active true}) + token (db/tx-run! th/*system* access-token/create-access-token + (:id profile) "MCP" nil "mcp")] + {:profile profile :token token})) + +(defn- request + ([token] (request token feedback)) + ([token params] (request token params {})) + ([token params options] + (with-redefs [cf/config (assoc cf/config :mcp-feedback-email "mcp@example.com")] + ((#'http/router-handler (::http/router th/*system*)) + (th/make-dummy-request + (merge {:path "/api/main/methods/send-mcp-feedback" + :method :post + :headers (cond-> {"content-type" "application/json" + "accept" "application/json" + "x-client" "penpot-mcp"} + token (assoc "authorization" (str "Token " token))) + :body-bytes (json/encode params)} + options)))))) + +(defn- emails + [] + (map #(jobs/decode-params (jobs/get-job-def (::jobs/defs th/*system*) :sendmail) (:params %)) + (db/query th/*system* :job {:name "sendmail"}))) + +(t/deftest queues-plain-text-feedback + (let [{:keys [token profile]} (create-identity) + response (request (:token token)) + email (first (emails))] + (t/is (= 200 (::yres/status response))) + (t/is (= 1 (count (emails)))) + (t/is (= "mcp@example.com" (:to email))) + (t/is (= (:email profile) (:reply-to email))) + (t/is (= "[MCP feedback] Export fails" (:subject email))) + (t/is (= {"text/plain" (str "Submitted by: " (:fullname profile) + " <" (:email profile) ">\n" + "Profile ID: " (:id profile) "\n\n" + "Feedback:\n\n" (:feedback feedback))} + (:body email))) + (t/is (nil? (:attachments email))))) + +(t/deftest rejects-invalid-credentials + (let [{:keys [profile token]} (create-identity)] + (doseq [credential [nil "" "invalid" + (tokens/generate th/*system* {:iss "other" + :uid (:id profile) :tid (:id token)}) + (tokens/generate th/*system* {:iss "access-token" + :uid (:id profile) :tid (:id token)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (uuid/next) :tid (:id token)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (:id profile) :tid (uuid/next)}) + (tokens/generate th/*system* {:iss "urn:penpot:mcp-token" + :uid (:id profile) :tid (:id token) + :exp (ct/minus (ct/now) (ct/duration "1h"))})]] + (t/is (= 401 (::yres/status (request credential))))) + (t/is (empty? (emails))))) + +(t/deftest rejects-expired-revoked-and-wrong-type-tokens + (let [{:keys [token profile]} (create-identity)] + (db/update! th/*system* :access-token + {:expires-at (ct/minus (ct/now) (ct/duration "1h"))} {:id (:id token)}) + (t/is (= 401 (::yres/status (request (:token token))))) + (db/update! th/*system* :access-token {:expires-at nil :type "personal"} {:id (:id token)}) + (t/is (= 401 (::yres/status (request (:token token))))) + (db/update! th/*system* :access-token {:type "mcp"} {:id (:id token)}) + (db/tx-run! th/*system* access-token/create-access-token (:id profile) "Replacement" nil "mcp") + (t/is (= 401 (::yres/status (request (:token token))))) + (t/is (empty? (emails))))) + +(t/deftest rejects-unavailable-profiles + (doseq [changes [{:is-active false} + {:is-blocked true} + {:deleted-at (ct/minus (ct/now) (ct/duration "1h"))}]] + (let [{:keys [token profile]} (create-identity)] + (db/update! th/*system* :profile changes {:id (:id profile)}) + (t/is (= (if (:deleted-at changes) 404 403) + (::yres/status (request (:token token))))))) + (t/is (empty? (emails)))) + +(t/deftest validates-feedback-before-queueing + (let [{:keys [token]} (create-identity)] + (doseq [params [{} + (assoc feedback :subject "") + (assoc feedback :subject " ") + (assoc feedback :subject "a\r\nBcc: victim@example.com") + (assoc feedback :subject "a\tb") + (assoc feedback :subject "a\u0085b") + (assoc feedback :subject "a\u0000b") + (assoc feedback :feedback "a\u0000b") + (assoc feedback :feedback "a\u001bb") + (assoc feedback :subject (apply str (repeat 121 "x"))) + (assoc feedback :feedback " \n") + (assoc feedback :feedback (apply str (repeat 5001 "x"))) + (assoc feedback :to "victim@example.com") + (assoc feedback :attachments {}) + (assoc feedback :feedback 42)]] + (t/is (= 400 (::yres/status (request (:token token) params))))) + (t/is (empty? (emails))) + (t/is (= 200 (::yres/status + (request (:token token) {:subject (apply str (repeat 120 "x")) + :feedback (apply str (repeat 5000 "x"))})))) + (t/is (= 200 (::yres/status + (request (:token token) {:subject "Tabs and breaks" + :feedback "line one\r\n\tline two\n"})))))) + +(t/deftest audit-event-omits-feedback-text + (let [{:keys [profile]} (create-identity)] + (with-redefs [cf/config (assoc cf/config :mcp-feedback-email "mcp@example.com")] + (let [result (th/command-raw! (merge feedback {::th/type :send-mcp-feedback + ::rpc/profile-id (:id profile) + ::rpc/auth-type :token + ::rpc/token-type "mcp"}))] + (t/is (= {} (:app.loggers.audit/replace-props (meta result)))))))) + +(t/deftest disabled-without-feedback-email + (let [{:keys [token]} (create-identity)] + (with-redefs [cf/config (dissoc cf/config :mcp-feedback-email)] + (let [response ((#'http/router-handler (::http/router th/*system*)) + (th/make-dummy-request + {:path "/api/main/methods/send-mcp-feedback" :method :post + :headers {"authorization" (str "Token " (:token token)) + "content-type" "application/json" + "x-client" "penpot-mcp"} + :body-bytes (json/encode feedback)}))] + (t/is (= 400 (::yres/status response))))) + (t/is (empty? (emails))))) + +(t/deftest feedback-is-post-only + (let [{:keys [token]} (create-identity)] + (t/is (= 405 (::yres/status (request (:token token) feedback {:method :get}))))) + (t/is (empty? (emails)))) + +(t/deftest mcp-token-does-not-authorize-ordinary-rpc + (let [{:keys [token]} (create-identity)] + (t/is (= 403 (::yres/status + (request (:token token) {} {:path "/api/rpc/command/get-teams"})))))) + +(t/deftest does-not-acknowledge-a-failed-queue-submission + (let [{:keys [token]} (create-identity)] + (with-redefs [email/send (fn [& _] (throw (ex-info "Queue unavailable" {})))] + (t/is (= 500 (::yres/status (request (:token token))))))) + (t/is (empty? (emails)))) diff --git a/backend/test/backend_tests/rpc_test.clj b/backend/test/backend_tests/rpc_test.clj index 233ffdd8e2..a6e18d24f3 100644 --- a/backend/test/backend_tests/rpc_test.clj +++ b/backend/test/backend_tests/rpc_test.clj @@ -902,6 +902,27 @@ (t/is (some? ex)) (t/is (th/ex-of-type? ex :not-found)))) +;; --- TOKEN TYPE RESTRICTIONS + +(t/deftest mcp-token-authentication + (let [pid (uuid/custom 1 1) + method (fn [mdata] + (rpc/make-rpc-handler + {:test-method [mdata (partial (#'rpc/wrap-authentication + nil (fn [_ params] params) mdata) + nil)]})) + handler (method {::rpc/auth-type :token ::rpc/token-type "mcp"})] + (doseq [extras [{::session/profile-id pid} + {::actoken/profile-id pid} + {::actoken/profile-id pid ::actoken/type "personal"}]] + (t/is (th/ex-of-code? (call-ex handler {:extras extras}) :token-auth-required))) + (let [extras {::actoken/profile-id pid ::actoken/type "mcp"}] + (t/is (= "mcp" (::rpc/token-type (call-body handler {:extras extras})))) + (t/is (th/ex-of-code? (call-ex (method {}) {:extras extras}) :token-auth-required))) + (t/is (= pid (::rpc/profile-id + (call-body (method {}) {:extras {::actoken/profile-id pid + ::actoken/type "personal"}})))))) + ;; --- PARAMS VALIDATION: SERVER AUTH CONTEXT VS CLIENT PARAMS (t/deftest client-qualified-keys-do-not-override-server-auth-context @@ -915,7 +936,8 @@ request {:params {:name "valid" :app.rpc/profile-id attacker-pid :app.rpc/auth-type :token - :app.rpc/token-perms #{"admin"}}} + :app.rpc/token-perms #{"admin"} + :app.rpc/token-type "mcp"}} params (with-meta {::rpc/profile-id server-pid ::rpc/auth-type :session} {::http/request request})] @@ -923,6 +945,7 @@ (t/is (= server-pid (::rpc/profile-id @captured))) (t/is (= :session (::rpc/auth-type @captured))) (t/is (not (contains? @captured ::rpc/token-perms))) + (t/is (not (contains? @captured ::rpc/token-type))) (t/is (= "valid" (:name @captured))))) ;; --- RESPONSE: NIL BODY WITH CUSTOM HEADERS diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 4c69760e24..5bca9bd6a0 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -191,6 +191,10 @@ services: PENPOT_SMTP_TLS: "false" PENPOT_SMTP_SSL: "false" + ## Inbox for the feedback that MCP agents send. Set it together with + ## PENPOT_MCP_BACKEND_URI on the penpot-mcp service. + # PENPOT_MCP_FEEDBACK_EMAIL: mcp-feedback@example.com + penpot-admin-console: image: "penpotapp/admin-console:${PENPOT_VERSION:-2.18}" restart: always @@ -215,6 +219,11 @@ services: networks: - penpot + # environment: + ## Offers the submit_feedback tool, which emails agent feedback through the + ## backend. Set it together with PENPOT_MCP_FEEDBACK_EMAIL on the backend. + # PENPOT_MCP_BACKEND_URI: http://penpot-backend:6060/ + penpot-exporter: image: "penpotapp/exporter:${PENPOT_VERSION:-2.18}" restart: always diff --git a/docs/mcp/index.md b/docs/mcp/index.md index fb9c927106..6b55a3f816 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -104,6 +104,9 @@ Current tools in **local MCP**: * `penpot_api_info` * `export_shape` * `import_image` +* `submit_feedback`, only when the server has `PENPOT_MCP_BACKEND_URI` set (see the [configuration guide](/technical-guide/configuration/#mcp)) + +`submit_feedback` emails plain-text feedback about Penpot MCP to the team that runs your Penpot instance. The agent sends it only when you ask or approve, and the email includes your name and email address. It needs your MCP key: the remote setup already sends it, and in local MCP you add it to the server URL as `userToken`. Because **remote MCP** does not expose local file-system access: diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 91c6ab740d..4759855e14 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -959,6 +959,30 @@ The defaults match the service name used in the official `docker-compose.yaml`. them only if your MCP service has a different name or listens on other ports. Both variables are ignored when the `enable-mcp` flag is not set. +#### MCP feedback + +Agents can email feedback about Penpot MCP to an inbox you choose, through the +`submit_feedback` tool. Set the backend URI on the MCP container to offer the tool, +and the inbox on the backend to accept the emails: + +```bash +# MCP +PENPOT_MCP_BACKEND_URI: http://penpot-backend:6060/ + +# Backend +PENPOT_MCP_FEEDBACK_EMAIL: mcp-feedback@example.com +``` + +- `PENPOT_MCP_BACKEND_URI`: The URI the MCP server uses to reach the backend. When + unset, the MCP server does not offer the tool. +- `PENPOT_MCP_FEEDBACK_EMAIL`: The address that receives the feedback. When unset, + the backend rejects submissions. + +The backend accepts feedback when the `enable-mcp` or `enable-access-tokens` flag is +set, and sends it through the [email configuration](#email-configuration). Each +email carries the name and email address of the user who sent it, and uses that +address as reply-to. + ### Internal resolver The frontend container resolves the backend, exporter and MCP service names with the diff --git a/mcp/README.md b/mcp/README.md index b38b60d502..4c206d9e35 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -290,7 +290,7 @@ The Penpot MCP server can be configured using environment variables. | Environment Variable | Description | Default | |--------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| | `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | -| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` | +| `PENPOT_MCP_SERVER_PORT` | Port for the MCP server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_REPL_HOST` | Address on which the REPL server listens (binds to) | `localhost` | @@ -301,6 +301,17 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | | `PENPOT_MCP_AUTH_URI` | Public base URL of the MCP authorization service, e.g. `https://penpot.example/api/mcp/oauth`. Set to enable OAuth; requires `--multi-user`. Use HTTPS (HTTP is allowed on loopback for development), without credentials, query parameters, or a fragment. | (unset; OAuth disabled) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | +| `PENPOT_MCP_BACKEND_URI` | Penpot instance base URI. Setting a nonblank value enables tools that communicate with the Penpot backend (currently only the `submit_feedback` tool). The MCP server appends suffixes such as `api/main/methods/send-mcp-feedback`. Backend interactions require MCP user tokens to be provided. | (unset) | + +### Backend Configuration + +If `PENPOT_MCP_BACKEND_URI` is set, the backend must itself be configured correspondingly, i.e. set +the following variables for the backend process: + +| Environment Variable | Description | Default | +|----------------------|-------------|---------| +| `PENPOT_MCP_FEEDBACK_EMAIL` | Selects the inbox for MCP feedback. Unset means the command rejects submissions. Requires the `enable-mcp` or `enable-access-tokens` flag and uses the backend's existing SMTP configuration. | (unset) | + ### Logging Configuration diff --git a/mcp/packages/server/src/Feedback.test.ts b/mcp/packages/server/src/Feedback.test.ts new file mode 100644 index 0000000000..b5b6460250 --- /dev/null +++ b/mcp/packages/server/src/Feedback.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { afterEach, beforeEach, test } from "node:test"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { PenpotMcpServer } from "./PenpotMcpServer"; +import { PenpotBackendClient } from "./PenpotBackendClient"; + +let previousEnv: NodeJS.ProcessEnv; +let backend: Server; +let backendUri: string; +let server: PenpotMcpServer | undefined; +let client: Client | undefined; +let requests: { url: string; authorization?: string; client?: string; body: unknown }[]; +let responseStatus: number; +let nextPort = 17_700; + +beforeEach(async () => { + previousEnv = { ...process.env }; + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(nextPort++); + process.env.PENPOT_MCP_WEBSOCKET_PORT = "0"; + process.env.PENPOT_MCP_DEVENV = "false"; + process.env.PENPOT_MCP_REPL_ENABLE = "false"; + delete process.env.PENPOT_MCP_REDIS_URI; + delete process.env.PENPOT_MCP_BACKEND_URI; + requests = []; + responseStatus = 200; + backend = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ + url: req.url!, + authorization: req.headers.authorization, + client: req.headers["x-client"] as string, + body: JSON.parse(body), + }); + res.writeHead(responseStatus, { + "content-type": "application/json", + ...(responseStatus === 307 ? { location: `${backendUri}/redirected` } : {}), + }); + res.end(JSON.stringify({ status: "queued", detail: "private backend diagnostics" })); + }); + backend.listen(0, "127.0.0.1"); + await once(backend, "listening"); + const address = backend.address(); + assert.ok(address && typeof address === "object"); + backendUri = `http://127.0.0.1:${address.port}/penpot`; +}); + +afterEach(async () => { + await client?.close(); + await server?.stop(); + client = undefined; + server = undefined; + backend.closeAllConnections(); + await new Promise((resolve, reject) => backend.close((error) => (error ? reject(error) : resolve()))); + process.env = previousEnv; +}); + +async function connect(multiUser: boolean, token?: string) { + server = new PenpotMcpServer(multiUser); + await server.start(); + client = new Client({ name: "feedback-test", version: "1" }); + const url = new URL(`http://127.0.0.1:${server.port}/mcp`); + if (token) url.searchParams.set("userToken", token); + await client.connect(new StreamableHTTPClientTransport(url)); + return client; +} + +const feedback = { subject: "Export fails", feedback: "Expected a PNG, received an error." }; + +for (const multiUser of [false, true]) { + test(`feedback is absent without a backend URI (multi-user=${multiUser})`, async () => { + const connected = await connect(multiUser, "alice"); + assert.ok(!(await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); + }); + + test(`feedback works without a plugin (multi-user=${multiUser})`, async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(multiUser, "alice"); + const tool = (await connected.listTools()).tools.find((tool) => tool.name === "submit_feedback"); + assert.ok(tool); + assert.deepEqual(tool.inputSchema.required, ["subject", "feedback"]); + const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); + assert.deepEqual(result.content, [ + { type: "text", text: "Feedback queued. It is emailed in the background, so delivery is not confirmed." }, + ]); + assert.deepEqual(requests, [ + { + url: "/penpot/api/main/methods/send-mcp-feedback", + authorization: "Token alice", + client: "penpot-mcp", + body: feedback, + }, + ]); + }); + + test(`missing credentials leave the tool listed but prevent submission (multi-user=${multiUser})`, async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(multiUser); + assert.ok((await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); + const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); + assert.match(JSON.stringify(result.content), /userToken.*MCP.*URL/); + assert.equal(requests.length, 0); + }); +} + +test("blank backend URI does not enable feedback", async () => { + process.env.PENPOT_MCP_BACKEND_URI = " "; + const connected = await connect(false); + assert.ok(!(await connected.listTools()).tools.some((tool) => tool.name === "submit_feedback")); +}); + +test("rejects invalid input without contacting the backend", async () => { + process.env.PENPOT_MCP_BACKEND_URI = backendUri; + const connected = await connect(false, "alice"); + for (const args of [ + { ...feedback, subject: "" }, + { ...feedback, subject: " " }, + { ...feedback, subject: "a\r\nBcc: other@example.com" }, + { ...feedback, subject: "a\tb" }, + { ...feedback, subject: "a\u0085b" }, + { ...feedback, feedback: "a\u0000b" }, + { ...feedback, feedback: "a\u001bb" }, + { ...feedback, subject: "x".repeat(121) }, + { ...feedback, feedback: " \n" }, + { ...feedback, feedback: "x".repeat(5001) }, + ]) { + // SDK versions may return a tool error or reject with a protocol error. + try { + const result = await connected.callTool({ name: "submit_feedback", arguments: args }); + assert.equal(result.isError, true); + } catch (error) { + assert.match(String(error), /invalid|validation/i); + } + } + assert.equal(requests.length, 0); +}); + +test("preserves deployment prefixes with and without a trailing slash", async () => { + for (const uri of [backendUri, backendUri + "/"]) { + await new PenpotBackendClient(uri).submitFeedback("alice", feedback); + } + assert.deepEqual( + requests.map((request) => request.url), + ["/penpot/api/main/methods/send-mcp-feedback", "/penpot/api/main/methods/send-mcp-feedback"] + ); +}); + +test("keeps credentials isolated across concurrent submissions", async () => { + const backendClient = new PenpotBackendClient(backendUri); + await Promise.all( + ["alice", "bob"].map((token) => + backendClient.submitFeedback(token, { + subject: token, + feedback: "A concrete problem.", + }) + ) + ); + assert.deepEqual( + requests + .map((request) => ({ + authorization: request.authorization, + subject: (request.body as typeof feedback).subject, + })) + .sort((a, b) => a.subject.localeCompare(b.subject)), + [ + { authorization: "Token alice", subject: "alice" }, + { authorization: "Token bob", subject: "bob" }, + ] + ); +}); + +for (const status of [400, 401, 403, 404, 429, 503, 500, 307]) { + test(`does not retry or expose response bodies on status ${status}`, async () => { + responseStatus = status; + const backendClient = new PenpotBackendClient(backendUri); + await assert.rejects(backendClient.submitFeedback("secret-token", feedback), (error: Error) => { + assert.doesNotMatch(error.message, /private backend diagnostics|secret-token/); + return true; + }); + assert.equal(requests.length, 1); + }); +} + +test("bounds network waits and does not retry an uncertain submission", async (t) => { + backend.removeAllListeners("request"); + let count = 0; + backend.on("request", (_req, _res) => { + count++; + }); + const timeout = AbortSignal.timeout.bind(AbortSignal); + t.mock.method(AbortSignal, "timeout", (milliseconds: number) => { + assert.equal(milliseconds, 10_000); + return timeout(50); + }); + await assert.rejects( + new PenpotBackendClient(backendUri).submitFeedback("secret-token", feedback), + /could not be confirmed.*Do not retry automatically/ + ); + assert.ok(count <= 1); +}); + +test("rejects invalid backend configuration without echoing credentials", () => { + for (const uri of [ + "invalid", + "file:///tmp", + "https://user:secret@example.com", + "https://example.com/?secret=value", + "https://example.com/#fragment", + ]) { + assert.throws( + () => new PenpotBackendClient(uri), + (error: Error) => { + assert.doesNotMatch(error.message, /secret|value|user:/); + return true; + } + ); + } +}); diff --git a/mcp/packages/server/src/PenpotBackendClient.ts b/mcp/packages/server/src/PenpotBackendClient.ts new file mode 100644 index 0000000000..07bebddf97 --- /dev/null +++ b/mcp/packages/server/src/PenpotBackendClient.ts @@ -0,0 +1,86 @@ +import { createLogger } from "./logger"; + +/** A plain-text MCP feedback report. */ +export interface Feedback { + subject: string; + feedback: string; +} + +/** Backend operations available to MCP tools. */ +export interface PenpotBackend { + submitFeedback(userToken: string, feedback: Feedback): Promise; +} + +/** Client for the configured Penpot instance's backend services. */ +export class PenpotBackendClient implements PenpotBackend { + private readonly logger = createLogger("PenpotBackendClient"); + private readonly baseUri: URL; + + constructor(baseUri: string) { + try { + this.baseUri = new URL(baseUri); + } catch { + throw new Error("PENPOT_MCP_BACKEND_URI must be an absolute HTTP(S) instance URI."); + } + if ( + !["http:", "https:"].includes(this.baseUri.protocol) || + this.baseUri.username || + this.baseUri.password || + this.baseUri.search || + this.baseUri.hash + ) { + throw new Error( + "PENPOT_MCP_BACKEND_URI must be an HTTP(S) instance URI without credentials, query or fragment." + ); + } + if (!this.baseUri.pathname.endsWith("/")) this.baseUri.pathname += "/"; + } + + public async submitFeedback(userToken: string, feedback: Feedback): Promise { + await this.callRpc("send-mcp-feedback", userToken, feedback); + } + + /** Calls a backend RPC method whose result body is not needed. */ + private async callRpc(methodName: string, userToken: string, params: object): Promise { + let response: Response; + try { + response = await fetch(new URL(`api/main/methods/${encodeURIComponent(methodName)}`, this.baseUri), { + method: "POST", + headers: { + Authorization: `Token ${userToken}`, + "X-Client": "penpot-mcp", + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(params), + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + // discard backend diagnostics, which may contain private data + await response.body?.cancel(); + } catch (error) { + this.logger.error(error, "Penpot RPC request failed"); + throw new Error("The backend operation could not be confirmed. Do not retry automatically."); + } + + if (response.ok) return; + + switch (response.status) { + case 401: + throw new Error( + "The backend did not accept the MCP user token. It may be outdated, " + + "or the Penpot instance may not have access tokens enabled." + ); + case 403: + throw new Error("The MCP user token is not authorized for this backend operation."); + case 400: + throw new Error("The backend rejected the request parameters or the operation is disabled."); + case 429: + throw new Error("The backend request limit has been reached. Do not retry automatically."); + default: + throw new Error( + `The backend operation could not be confirmed (HTTP ${response.status}). Do not retry automatically.` + ); + } + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 2ec5cbe146..445fb4ee4e 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -8,17 +8,19 @@ import { ExecuteCodeTool } from "./tools/ExecuteCodeTool"; import { PluginBridge } from "./PluginBridge"; import { RedisBridge } from "./RedisBridge"; import { ConfigurationLoader } from "./ConfigurationLoader"; +import { PenpotBackendClient } from "./PenpotBackendClient"; +import { SubmitFeedbackTool } from "./tools/SubmitFeedbackTool"; import { createLogger } from "./logger"; import { Tool } from "./Tool"; import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; -import { CljsReplTool } from "./tools/CljsReplTool"; -import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; -import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; -import { CljCheckParentheses } from "./tools/CljCheckParentheses"; -import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; +import { CljsReplTool } from "./tools/dev/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/dev/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/dev/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/dev/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/dev/ReadTaigaIssueTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -264,6 +266,10 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + const backendUri = process.env.PENPOT_MCP_BACKEND_URI?.trim(); + if (backendUri) { + toolInstances.push(new SubmitFeedbackTool(this, new PenpotBackendClient(backendUri))); + } if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) { const nreplClient = new NreplClient(); toolInstances.push(new CljsReplTool(this, nreplClient)); diff --git a/mcp/packages/server/src/tools/SubmitFeedbackTool.ts b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts new file mode 100644 index 0000000000..4c7b92f952 --- /dev/null +++ b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import type { Feedback, PenpotBackend } from "../PenpotBackendClient"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import { Tool } from "../Tool"; +import { TextResponse, type ToolResponse } from "../ToolResponse"; + +/** Arguments for submitting a plain-text feedback report. */ +export class SubmitFeedbackArgs implements Feedback { + static schema = { + subject: z + .string() + .min(1) + .max(120) + .regex(/\S/) + .regex(/^\P{Cc}*$/u) + .describe("Short, specific summary without line breaks or other control characters (max 120 characters)."), + feedback: z + .string() + .min(1) + .max(5000) + .regex(/\S/) + // line breaks and tabs are the only control characters allowed + .regex(/^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]*$/) + .describe("Concise plain-text feedback (max 5,000 characters)."), + }; + + subject!: string; + feedback!: string; +} + +/** Tool for user-approved feedback about the Penpot MCP integration. */ +export class SubmitFeedbackTool extends Tool { + constructor( + mcpServer: PenpotMcpServer, + private readonly backend: PenpotBackend + ) { + super(mcpServer, SubmitFeedbackArgs.schema); + } + + public getToolName(): string { + return "submit_feedback"; + } + + public getToolDescription(): string { + return ( + "Submit feedback about Penpot MCP, provided that the user explicitly requests or approves submission " + + "and there is a concrete, actionable issue to report. " + + "Focus on bugs, limitations, or unclear documentation that blocked work or caused substantial rework. " + + "Briefly propose what to report and ask the user at a natural pause. " + + "Skip vague dissatisfaction, routine transient errors, and your own coding mistakes. " + + "Describe the operation, expected and actual behavior, and any workaround. Exclude sensitive content." + ); + } + + protected formatArgs(_args: SubmitFeedbackArgs): string { + return "[feedback omitted]"; + } + + protected async executeCore(args: SubmitFeedbackArgs): Promise { + const userToken = this.getSessionContext()?.userToken; + if (!userToken?.trim()) { + throw new Error("Include userToken in the MCP server URL to submit feedback."); + } + await this.backend.submitFeedback(userToken, { subject: args.subject, feedback: args.feedback }); + return new TextResponse("Feedback queued. It is emailed in the background, so delivery is not confirmed."); + } +} diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/dev/CljCheckParentheses.ts similarity index 97% rename from mcp/packages/server/src/tools/CljCheckParentheses.ts rename to mcp/packages/server/src/tools/dev/CljCheckParentheses.ts index 81e2a9628c..d7a5af6fd2 100644 --- a/mcp/packages/server/src/tools/CljCheckParentheses.ts +++ b/mcp/packages/server/src/tools/dev/CljCheckParentheses.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import type { PenpotMcpServer } from "../PenpotMcpServer"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import type { PenpotMcpServer } from "../../PenpotMcpServer"; import * as fs from "fs"; /** diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts similarity index 86% rename from mcp/packages/server/src/tools/CljsCompilerOutputTool.ts rename to mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts index 11989c6303..772d9461df 100644 --- a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts +++ b/mcp/packages/server/src/tools/dev/CljsCompilerOutputTool.ts @@ -1,9 +1,9 @@ -import { Tool, EmptyToolArgs } from "../Tool"; +import { Tool, EmptyToolArgs } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; /** * Reports the compiler status of the shadow-cljs `:main` build. diff --git a/mcp/packages/server/src/tools/CljsReplTool.ts b/mcp/packages/server/src/tools/dev/CljsReplTool.ts similarity index 89% rename from mcp/packages/server/src/tools/CljsReplTool.ts rename to mcp/packages/server/src/tools/dev/CljsReplTool.ts index bd894caef3..ab2babf56d 100644 --- a/mcp/packages/server/src/tools/CljsReplTool.ts +++ b/mcp/packages/server/src/tools/dev/CljsReplTool.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; /** * Arguments for the CljsReplTool. diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts similarity index 98% rename from mcp/packages/server/src/tools/ImportPenpotFileTool.ts rename to mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts index f55ae194f2..50d18f5ae5 100644 --- a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts +++ b/mcp/packages/server/src/tools/dev/ImportPenpotFileTool.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { Tool } from "../Tool"; -import { TextResponse, ToolResponse } from "../ToolResponse"; +import { Tool } from "../../Tool"; +import { TextResponse, ToolResponse } from "../../ToolResponse"; import "reflect-metadata"; -import { PenpotMcpServer } from "../PenpotMcpServer"; -import { NreplClient } from "../NreplClient"; -import { createLogger } from "../logger"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; +import { NreplClient } from "../../NreplClient"; +import { createLogger } from "../../logger"; import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts similarity index 96% rename from mcp/packages/server/src/tools/ReadTaigaIssueTool.ts rename to mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts index 29e8bea460..decd926182 100644 --- a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts +++ b/mcp/packages/server/src/tools/dev/ReadTaigaIssueTool.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { Tool } from "../Tool"; +import { Tool } from "../../Tool"; import "reflect-metadata"; -import type { ToolResponse } from "../ToolResponse"; -import { TextResponse } from "../ToolResponse"; -import { PenpotMcpServer } from "../PenpotMcpServer"; +import type { ToolResponse } from "../../ToolResponse"; +import { TextResponse } from "../../ToolResponse"; +import { PenpotMcpServer } from "../../PenpotMcpServer"; /** * Arguments for the {@link ReadTaigaIssueTool}. From 2607ec89fa2a000c410b33b303912925d3fc777d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 12:09:56 +0100 Subject: [PATCH 189/325] :recycle: Move the profile page to the DS and make the Update overlay a button (#12651) * :recycle: Make the profile photo Update overlay a button The "Update" overlay on the profile photo was a span with an on-click, so the keyboard could not reach it. It is now a button: it shows on keyboard focus as well as on hover. The button centers its line in the circle, so a 4px bottom padding keeps the label where the span drew it (0px difference). New ds visual test "Profile page controls": the page at rest, the overlay on hover and on focus, Save enabled, and the Change email link on hover. AI-assisted-by: claude-opus-5-5 * :recycle: Move profile page styles off the deprecated styles profile.scss imported the whole deprecated module and kept a long copy of shared settings rules. It now uses the DS tokens, sizes, borders, z-index and typography. The deprecated import stays only for %button-primary: the save button is the shared forms submit button. Deleted selectors that match nothing in profile.cljs: .btn-secondary (both), .field, .field-title, .field-text, .custom-input, .custom-select, .two-columns, .subtitle, .notification-icon, .notification-text, .notification-text-email, .newsletter-subs, the h1, h3, p and hr rules, the form rule (both forms set their own flex) and the link hover underline (the global link rule already does it). Typographies: the page title uses title-large (line-height 1.4 instead of 1.15), the Change email link title-small (line-height 1.2 instead of the default), and the Update overlay title-large with its own line-height. Visible change: the taller title line moves the title 3px down and the rest of the page 6px down. Nothing else changes. AI-assisted-by: claude-opus-5-5 * :recycle: Give the profile page links, title and photo classes The links and the profile photo get their own classes instead of bare a and img selectors, and the page title uses the DS heading with title-large (it already used that typography). The link wrapper drops a class with no styles, and the rule for the file input goes: the shared uploader hides that input inline, so the rule did nothing. No visual change. AI-assisted-by: claude-opus-5-5 * :lipstick: Use one DS typography and the accent color for the profile links "Change email" took title-small from its container, overridden by the global link styles (14px, weight 500), and "Want to remove your account?" had no typography and inherited 16px. Both links now use body-medium and --color-accent-primary, as design asked in the review. The "Change email" line is taller with the 1.4 line height, so Save settings and the link below it sit 3px lower. AI-assisted-by: claude-opus-5-5 --- .../ui/visual-specs/visual-dashboard.spec.js | 36 ++ .../src/app/main/ui/settings/profile.cljs | 25 +- .../src/app/main/ui/settings/profile.scss | 443 +++++------------- 3 files changed, 168 insertions(+), 336 deletions(-) diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 878a02d347..932ebb7061 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -833,6 +833,42 @@ test.describe("Onboarding questions controls", () => { }); }); +test.describe("Profile page controls", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Profile page avatar, form and links", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + await dashboardPage.goToAccount(); + + const container = page.locator( + "[class*='settings_profile__form-container']", + ); + await expect(page.getByText("Change email")).toBeVisible(); + await page.mouse.move(10, 700); + await expect(container).toHaveScreenshot(shot); + + // The "Update" overlay shows when the pointer is on the avatar. + await page.locator("[class*='image-change-field']").hover(); + await expect(container).toHaveScreenshot(shot); + + // Save turns enabled once the name changes. + await page.getByLabel("Your name").fill("Renamed user"); + await page.mouse.move(10, 700); + await expect(container).toHaveScreenshot(shot); + + await page.getByText("Change email").hover(); + await expect(container).toHaveScreenshot(shot); + + // The "Update" overlay is a button: it shows on keyboard focus too. + await page.mouse.move(10, 700); + await page.getByRole("button", { name: "Update" }).focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(container).toHaveScreenshot(shot); + }); +}); + test.describe("Templates section controls", () => { const shot = { maxDiffPixelRatio: 0, threshold: 0 }; diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 1c1add422b..74f073ae36 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -17,6 +17,8 @@ [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.forms :as fm] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -71,7 +73,8 @@ [:div {:class (stl/css :options)} [:div.change-email - [:a {:on-click on-show-change-email} + [:a {:class (stl/css :link) + :on-click on-show-change-email} (tr "dashboard.change-email")]]]] [:> fm/submit-button* @@ -80,8 +83,9 @@ :class (stl/css :btn-primary)}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-item)} - [:a {:on-click on-show-delete-account + [:div + [:a {:class (stl/css :link) + :on-click on-show-delete-account :data-testid "remove-acount-btn"} (tr "dashboard.remove-account")]]]])) @@ -119,9 +123,12 @@ [:form {:class (stl/css :avatar-form)} [:div {:class (stl/css :image-change-field)} - [:span {:class (stl/css :update-overlay) - :on-click on-image-click} (tr "labels.update")] - [:img {:src photo}] + [:button {:type "button" + :class (stl/css :update-overlay) + :on-click on-image-click} + (tr "labels.update")] + [:img {:class (stl/css :avatar-image) + :src photo}] (when has-photo? [:button {:type "button" :class (stl/css :delete-overlay) @@ -146,6 +153,10 @@ [:section {:class (stl/css :dashboard-settings) :aria-labelledby "profile-section-title"} [:div {:class (stl/css :form-container)} - [:h2 {:id "profile-section-title"} (tr "labels.profile")] + [:> heading* {:level 2 + :typography t/title-large + :id "profile-section-title" + :class (stl/css :page-title)} + (tr "labels.profile")] [:> profile-photo-form*] [:> profile-form*]]]) diff --git a/frontend/src/app/main/ui/settings/profile.scss b/frontend/src/app/main/ui/settings/profile.scss index 1a6fca03c6..0c2b954a18 100644 --- a/frontend/src/app/main/ui/settings/profile.scss +++ b/frontend/src/app/main/ui/settings/profile.scss @@ -4,316 +4,83 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "common/refactor/common-refactor" as *; +// Only for %button-primary: the save button is the shared forms submit +// button, which moves to the DS with the forms component. +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .dashboard-settings { display: flex; - width: 100%; + inline-size: 100%; justify-content: center; align-items: center; +} - a:not(.button-primary, .link) { - color: var(--color-foreground-secondary); - } +.link { + @include use-typography("body-medium"); + + color: var(--color-accent-primary); } .form-container { display: flex; flex-direction: column; - max-width: $s-500; - width: $s-580; - margin: $s-80 auto $s-120 auto; + max-inline-size: $sz-500; + inline-size: px2rem(580); + margin-block: px2rem(80) px2rem(120); + margin-inline: auto; justify-content: center; - - form { - display: flex; - flex-direction: column; - - .btn-secondary { - width: 100%; - font-size: $fs-11; - text-transform: uppercase; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - - &:hover { - color: var(--color-accent-primary); - background-color: var(--color-background-quaternary); - } - } - - hr { - display: none; - } - } - - .fields-row { - --input-height: #{$s-40}; - - margin-bottom: $s-20; - flex-direction: column; - - .options { - display: flex; - justify-content: flex-end; - font-size: $fs-14; - margin-top: $s-12; - } - } - - .field { - margin-bottom: $s-20; - } - - .field-title { - color: var(--color-foreground-primary); - - &:not(:first-child) { - margin-top: $s-64; - } - } - - .field-text { - color: var(--color-foreground-secondary); - } - - .custom-input, - .custom-select { - flex-direction: column-reverse; - - label { - position: relative; - text-transform: uppercase; - color: var(--color-foreground-primary); - font-size: $fs-11; - margin-bottom: $s-12; - margin-left: calc(-1 * $s-4); - } - - input, - select { - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border-color: transparent; - color: var(--color-foreground-primary); - padding: 0 $s-16; - - &:focus { - outline: $s-1 solid var(--color-accent-primary); - } - - ::placeholder { - color: var(--color-foreground-secondary); - } - } - - .help-icon { - bottom: $s-12; - top: auto; - - svg { - fill: var(--color-foreground-secondary); - } - } - - &.disabled { - input { - background-color: var(--input-background-color-disabled); - border-color: var(--color-background-quaternary); - color: var(--color-foreground-secondary); - } - } - - .input-container { - background-color: var(--color-background-tertiary); - border-radius: $br-8; - border-color: transparent; - margin-top: $s-24; - - .main-content { - label { - position: absolute; - top: calc(-1 * $s-24); - } - - span { - color: var(--color-foreground-primary); - } - } - - &:focus { - border: $s-1 solid var(--color-accent-primary); - } - } - - textarea { - border-radius: $br-8; - padding: $s-12 $s-16; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - border: none; - - &:focus { - outline: $s-1 solid var(--color-accent-primary); - } - } - } - - &.two-columns { - max-width: $s-520; - justify-content: space-between; - flex-direction: row; - } - - h1 { - font-size: $fs-36; - color: var(--color-background-tertiary); - margin-bottom: $s-20; - } - - .subtitle { - font-size: $fs-24; - color: var(--color-background-tertiary); - margin-bottom: $s-20; - } - - .notification-icon { - justify-content: center; - display: flex; - margin-bottom: $s-48; - - svg { - fill: var(--color-background-primary); - height: 40%; - width: 40%; - } - } - - .notification-text { - font-size: $fs-16; - color: var(--color-background-primary); - margin-bottom: $s-20; - } - - .notification-text-email { - background: var(--color-foreground-primary); - border-radius: $br-4; - color: var(--color-background-primary); - font-size: $fs-16; - font-weight: $fw500; - margin: $s-24 0 $s-40 0; - padding: $s-16; - text-align: center; - } - - h2 { - font-size: $fs-24; - font-weight: $fw400; - color: var(--color-foreground-primary); - display: flex; - align-items: center; - margin: $s-16 0; - } - - h3 { - font-size: $fs-12; - font-weight: $fw400; - color: var(--color-foreground-primary); - display: flex; - align-items: center; - margin: $s-8 0; - text-transform: uppercase; - } - - a:not(.button-primary) { - &:hover { - text-decoration: underline; - } - } - - p { - color: var(--color-background-primary); - } - - hr { - border-color: var(--color-foreground-secondary); - } - - .links { - margin-top: $s-12; - } } -form.avatar-form { +.page-title { + color: var(--color-foreground-primary); + display: flex; + align-items: center; + margin-block: var(--sp-l); + margin-inline: 0; +} + +.fields-row { + --input-height: #{$sz-40}; + + margin-block-end: var(--sp-xl); + flex-direction: column; +} + +.options { + display: flex; + justify-content: flex-end; + margin-block-start: var(--sp-m); +} + +.links { + margin-block-start: var(--sp-m); +} + +.avatar-form { display: flex; flex-direction: column; - width: $s-148; - height: $s-148; - margin: $s-16 0; + inline-size: px2rem(148); + block-size: px2rem(148); + margin-block: var(--sp-l); + margin-inline: 0; +} - img { - border-radius: 50%; - flex-shrink: 0; - height: 100%; - margin-right: $s-16; - width: 100%; - } +.avatar-image { + border-radius: $br-circle; + flex-shrink: 0; + block-size: 100%; + margin-inline-end: var(--sp-l); + inline-size: 100%; } .image-change-field { position: relative; - width: 100%; - height: 100%; - - .update-overlay { - opacity: 0; - cursor: pointer; - position: absolute; - width: 100%; - height: 100%; - border-radius: 50%; - font-size: $fs-24; - color: var(--color-foreground-primary); - line-height: 6; - text-align: center; - background: var(--color-accent-tertiary); - z-index: $z-index-modal; - } - - .delete-overlay { - position: absolute; - top: $s-4; - inset-inline-end: $s-4; - display: flex; - align-items: center; - justify-content: center; - width: $s-32; - height: $s-32; - padding: 0; - border: none; - border-radius: 50%; - background: var(--color-background-primary); - color: var(--color-foreground-primary); - cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease-in-out; - z-index: calc(#{$z-index-modal} + 1); - - &:hover { - background: var(--color-background-quaternary); - color: var(--color-accent-primary); - } - } - - input[type="file"] { - width: 100%; - height: 100%; - position: absolute; - opacity: 0; - cursor: pointer; - top: 0; - z-index: $z-index-modal; - } + inline-size: 100%; + block-size: 100%; &:hover { .update-overlay { @@ -326,52 +93,70 @@ form.avatar-form { } } +.update-overlay { + @include use-typography("title-large"); + + opacity: 0; + cursor: pointer; + position: absolute; + inline-size: 100%; + block-size: 100%; + border: none; + border-radius: $br-circle; + color: var(--color-foreground-primary); + + // Draws the label in the middle of the circle. + line-height: 6; + text-align: center; + background: var(--color-accent-tertiary); + + // The button centers its 144px line in the 148px circle; the bottom + // padding keeps the label where the old span drew it. + padding: 0 0 var(--sp-xs); + z-index: var(--z-index-set); + + &:focus-visible { + opacity: 0.8; + } +} + +.delete-overlay { + --delete-overlay-background-color: var(--color-background-primary); + --delete-overlay-foreground-color: var(--color-foreground-primary); + + position: absolute; + inset-block-start: var(--sp-xs); + inset-inline-end: var(--sp-xs); + display: flex; + align-items: center; + justify-content: center; + inline-size: $sz-32; + block-size: $sz-32; + padding: 0; + border: none; + border-radius: $br-circle; + background: var(--delete-overlay-background-color); + color: var(--delete-overlay-foreground-color); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease-in-out; + z-index: calc(var(--z-index-set) + 1); + + &:hover { + --delete-overlay-background-color: var(--color-background-quaternary); + --delete-overlay-foreground-color: var(--color-accent-primary); + } +} + .profile-form { display: flex; flex-direction: column; - max-width: $s-368; - width: 100%; -} - -.newsletter-subs { - border-bottom: $s-1 solid var(--color-foreground-secondary); - border-top: $s-1 solid var(--color-foreground-secondary); - padding: $s-32 0; - margin-bottom: $s-32; - - .newsletter-title { - font-family: "worksans", "vazirmatn", sans-serif; - color: var(--color-foreground-secondary); - font-size: $fs-14; - } - - label { - font-family: "worksans", "vazirmatn", sans-serif; - color: var(--color-background-primary); - font-size: $fs-12; - margin-right: calc(-1 * $s-16); - margin-bottom: $s-12; - } - - .info { - color: var(--color-foreground-secondary); - font-size: $fs-12; - margin-bottom: $s-8; - } - - .input-checkbox label { - align-items: flex-start; - } -} - -.btn-secondary { - @extend %button-secondary; - - height: $s-32; + max-inline-size: px2rem(368); + inline-size: 100%; } .btn-primary { @extend %button-primary; - height: $s-32; + block-size: $sz-32; } From c373e8da8839caa21c8b9cf9161047c9e0c3ab80 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 14:50:20 +0100 Subject: [PATCH 190/325] :wrench: Run the DS visual specs on the dark theme (#12707) Since accounts without a theme follow the system one (#12480), the ds Playwright project renders the light theme: headless Chromium reports a light color scheme. Its screenshots stopped matching the ones taken before, and the app is reviewed on the dark theme. Set colorScheme to dark for the ds project only; the default and render-wasm projects keep their behavior. AI-assisted-by: claude-opus-5-5 --- frontend/playwright.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index 1e74d525eb..087e6ba56f 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -65,7 +65,9 @@ export default defineConfig({ }, { name: "ds", - use: { ...devices["Desktop Chrome"] }, + // Accounts without a theme follow the system one; keep the visual + // specs on the dark theme so their screenshots stay comparable. + use: { ...devices["Desktop Chrome"], colorScheme: "dark" }, testDir: "./playwright/ui/visual-specs", expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.005 }, From c0af48f8730f7ccac7e02ec3d692f3b13ec185c4 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:24:22 +0100 Subject: [PATCH 191/325] :arrow_up: Update DHI base images (#12713) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13@2664ef2 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@cc4e05f (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.8-alpine3.24@83489ce (docker/images/Dockerfile.storybook) - dhi.io/nginx 1.31.8-debian13-dev@fbff190 (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.2@ (docker/devenv/Dockerfile) - dhi.io/node 24.22.2@ (.nvmrc) - dhi.io/node 24.22.2-debian13-dev@53af56f (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.2-debian13@24fa625 (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.2-debian13-dev@53af56f (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.2-debian13-dev@53af56f (docker/images/Dockerfile.exporter) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- .nvmrc | 2 +- docker/devenv/Dockerfile | 8 ++++---- docker/imagemagick/Dockerfile | 4 ++-- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 4 ++-- docker/images/Dockerfile.media-processor | 2 +- docker/images/Dockerfile.storybook | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.nvmrc b/.nvmrc index 474d750b50..86456ddf02 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.22.1 +v24.22.2 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index ba6b51f2ca..b385f0983d 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.22.1 \ +ENV NODE_VERSION=v24.22.2 \ PNPM_VERSION=12.15.0 \ PATH=/opt/node/bin:$PATH @@ -76,14 +76,14 @@ RUN set -eux; \ aarch64|arm64) \ OPENSSL_ARCH='linux-aarch64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - NODE_SHA256='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ + NODE_SHA256='95c264150aceb7f3437a29fd6984924f5f8d1511ea3cbd6c508b83f6c555dae2'; \ PNPM_ARCH='arm64'; \ PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ + NODE_SHA256='361718c444e49af2ca5a110c83ba6a8901143f23ab285ebbef0341f391f482a9'; \ PNPM_ARCH='x64'; \ PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index f875c09a95..cdef6f2f3e 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13@sha256:de80b468d8c492508f24b30e529accfaa8c59fe4505ddd72e969b324d276ad21 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:2664ef25f3f63f5545a121a6a4676bf70da18df2bcadece1a913d6daaa19fdfb AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 32e8c2c1fa..4618c1ba91 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:cc4e05ff415ee2d83918f2af45c737983209924ef7362c53f00cfcdaa1c94326 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 97daca2dfe..6c15b6c5a1 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 26f84d4640..05939a1982 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-debian13-dev@sha256:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 +FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:fbff190f9162256975ed225adcc9a294e333897de543fd18be630de1d6c8de17 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f018302d33..fac387760d 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -42,7 +42,7 @@ RUN set -eux; \ RUN ./setup -FROM dhi.io/node:24.22.1-debian13@sha256:32a2fa6af5209698a9354cef0b19ada3dbfac3090b1010b70afa9f1a298ba2d9 AS image +FROM dhi.io/node:24.22.2-debian13@sha256:24fa6258b891ef66e6c3b7ed82daad550b81cdfcd76f6cc64eee4d2478366747 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index ee0694bc34..89a582e8d0 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.22.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 +FROM dhi.io/node:24.22.2-debian13-dev@sha256:53af56f14166cffcf9c4b9ef6f7025d1b0534edddfa1f333c53bd54766f39e5c LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 2b259ffb61..4832c60054 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.7-alpine3.24@sha256:6d3811fbc76c5d0652752b39a003769759ccfeb2c1e874bacf99e3d44e5da3b9 +FROM dhi.io/nginx:1.31.8-alpine3.24@sha256:83489ce440fa56cb5149c088dbf75422362e45c532c814100253e5ab71871366 LABEL maintainer="Penpot " ARG BUNDLE_PATH="./bundle-storybook/" From 936371d9a6d4e51f4ab9e51714a10085388de856 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:26:20 +0100 Subject: [PATCH 192/325] :arrow_up: Update dependency pnpm to v12.16.1 (#12714) - pnpm 12.16.1 (docker/images/Dockerfile.media-processor) - pnpm 12.16.1 (docker/images/Dockerfile.mcp) - pnpm 12.16.1 (docker/images/Dockerfile.exporter) - pnpm 12.16.1 (docker/devenv/Dockerfile) - pnpm 12.16.1 (render-wasm/package.json) - pnpm 12.16.1 (plugins/package.json) - pnpm 12.16.1 (plugins/libs/plugins-styles/package.json) - pnpm 12.16.1 (plugins/libs/plugins-runtime/package.json) - pnpm 12.16.1 (plugins/libs/plugin-types/package.json) - pnpm 12.16.1 (plugins/apps/table-plugin/package.json) - pnpm 12.16.1 (plugins/apps/rename-layers-plugin/package.json) - pnpm 12.16.1 (plugins/apps/poc-tokens-plugin/package.json) - pnpm 12.16.1 (plugins/apps/poc-state-plugin/package.json) - pnpm 12.16.1 (plugins/apps/plugin-api-test-suite/package.json) - pnpm 12.16.1 (plugins/apps/lorem-ipsum-plugin/package.json) - pnpm 12.16.1 (plugins/apps/icons-plugin/package.json) - pnpm 12.16.1 (plugins/apps/example-styles/package.json) - pnpm 12.16.1 (plugins/apps/e2e/package.json) - pnpm 12.16.1 (plugins/apps/create-palette-plugin/package.json) - pnpm 12.16.1 (plugins/apps/contrast-plugin/package.json) - pnpm 12.16.1 (plugins/apps/composable-test-suite/package.json) - pnpm 12.16.1 (plugins/apps/colors-to-tokens-plugin/package.json) - pnpm 12.16.1 (package.json) - pnpm 12.16.1 (media-processor/package.json) - pnpm 12.16.1 (mcp/packages/server/package.json) - pnpm 12.16.1 (mcp/packages/plugin/package.json) - pnpm 12.16.1 (mcp/packages/common/package.json) - pnpm 12.16.1 (mcp/package.json) - pnpm 12.16.1 (library/package.json) - pnpm 12.16.1 (frontend/text-editor/package.json) - pnpm 12.16.1 (frontend/packages/ui/package.json) - pnpm 12.16.1 (frontend/packages/mousetrap/package.json) - pnpm 12.16.1 (frontend/packages/draft-js/package.json) - pnpm 12.16.1 (frontend/package.json) - pnpm 12.16.1 (exporter/package.json) - pnpm 12.16.1 (docs/package.json) - pnpm 12.16.1 (common/package.json) - pnpm 12.16.1 (backend/package.json) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- backend/package.json | 2 +- backend/pnpm-lock.yaml | 122 +++++++++--------- common/package.json | 2 +- common/pnpm-lock.yaml | 122 +++++++++--------- docker/devenv/Dockerfile | 6 +- docker/images/Dockerfile.exporter | 6 +- docker/images/Dockerfile.mcp | 6 +- docker/images/Dockerfile.media-processor | 6 +- docs/package.json | 2 +- docs/pnpm-lock.yaml | 122 +++++++++--------- exporter/package.json | 2 +- exporter/pnpm-lock.yaml | 122 +++++++++--------- frontend/package.json | 2 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/ui/package.json | 2 +- frontend/pnpm-lock.yaml | 122 +++++++++--------- frontend/text-editor/package.json | 2 +- library/package.json | 2 +- library/pnpm-lock.yaml | 122 +++++++++--------- mcp/package.json | 2 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 2 +- mcp/pnpm-lock.yaml | 122 +++++++++--------- media-processor/package.json | 2 +- media-processor/pnpm-lock.yaml | 122 +++++++++--------- package.json | 2 +- .../apps/colors-to-tokens-plugin/package.json | 2 +- .../apps/composable-test-suite/package.json | 2 +- plugins/apps/contrast-plugin/package.json | 2 +- .../apps/create-palette-plugin/package.json | 2 +- plugins/apps/e2e/package.json | 2 +- plugins/apps/example-styles/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- .../apps/plugin-api-test-suite/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- .../apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- plugins/package.json | 2 +- plugins/pnpm-lock.yaml | 122 +++++++++--------- pnpm-lock.yaml | 122 +++++++++--------- render-wasm/package.json | 2 +- render-wasm/pnpm-lock.yaml | 122 +++++++++--------- 49 files changed, 717 insertions(+), 717 deletions(-) diff --git a/backend/package.json b/backend/package.json index f877761df9..f573defead 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index ec457e4c24..8d3278fb84 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/common/package.json b/common/package.json index 2c63d73b31..2569c0fb8c 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 182b225f53..cd80e3a302 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index b385f0983d..0d8f85586b 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -67,7 +67,7 @@ RUN set -ex; \ FROM base AS setup-node ENV NODE_VERSION=v24.22.2 \ - PNPM_VERSION=12.15.0 \ + PNPM_VERSION=12.16.1 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -78,14 +78,14 @@ RUN set -eux; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ NODE_SHA256='95c264150aceb7f3437a29fd6984924f5f8d1511ea3cbd6c508b83f6c555dae2'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ OPENSSL_ARCH='linux-x86_64'; \ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ NODE_SHA256='361718c444e49af2ca5a110c83ba6a8901143f23ab285ebbef0341f391f482a9'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 6c15b6c5a1..b427e0bc80 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -95,17 +95,17 @@ WORKDIR /opt/penpot/exporter # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); the generated ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index fac387760d..aac0ff2a38 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -11,7 +11,7 @@ WORKDIR /opt/penpot/mcp # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install curl ca-certificates gzip; \ @@ -20,11 +20,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index 89a582e8d0..b2e9eb4af2 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -58,7 +58,7 @@ WORKDIR /opt/penpot/media-processor # pnpm ships as a system binary (same tarball + SHA pin as # docker/devenv/Dockerfile); ./setup expects it on PATH. # Corepack is gone from Node 25+, so nothing here may use it. -ARG PNPM_VERSION=12.15.0 +ARG PNPM_VERSION=12.16.1 RUN set -eux; \ apt-get -qq update; \ apt-get -qqy --no-install-recommends install gzip; \ @@ -67,11 +67,11 @@ RUN set -eux; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ + PNPM_SHA256='f5847699e5d528f8f8e9bcfb385e58cb9fba7482af1b1aa7b70737be42277f81'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ + PNPM_SHA256='565ff81174a14f6f7bd5d84c1b17e51031a80230d370a1fe24be5d16bd02a825'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docs/package.json b/docs/package.json index 076c461693..4d1cae270b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,5 +39,5 @@ "markdown-it-anchor": "^10.0.0", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 460dc46c55..e647b1e78b 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/exporter/package.json b/exporter/package.json index 40c8f6e923..d4eeab2c7f 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 843416db40..1f88853fc8 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/package.json b/frontend/package.json index 4e1433e206..4a73f7a7ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "browserslist": [ "defaults" ], diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 293e69d269..6531f4ca78 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 3eb605929d..3c22c33ff5 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "node --test \"test/*.test.js\"" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index cd8c59aad1..aea882f601 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "types": "./dist/index.d.ts", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "exports": { ".": { "import": "./dist/index.js" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6a0901cc8c..3efa68def5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 02cbcf2349..45fc013e71 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^5.0.2" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/library/package.json b/library/package.json index e41f4a6480..6b1911e2bd 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0", "license": "MPL-2.0", "author": "KALEIDOS SUBSIDIARY SL", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index d8a67b195d..6a7c1982b3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/mcp/package.json b/mcp/package.json index a62d084e1f..f80b14da00 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "devDependencies": { "concurrently": "^10.0.5", "prettier": "^3.9.9" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index b87f8ce17b..6ddd0bd605 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index ffa4f3837a..add5b088f8 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index ee816a759b..e2c82726fb 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", "@modelcontextprotocol/server": "^2.3.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 7b7f98e50e..f087bae740 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/media-processor/package.json b/media-processor/package.json index 4a79f0ae66..29001cae44 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "dependencies": { "express": "^5.2.1", "multer": "^2.4.0", diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index 147a963c3e..eb00e8a1b9 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/package.json b/package.json index 05df2e97cd..5a14003bcd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index a762e7494c..4c9690e2a2 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 1f21539f19..f12e809fe0 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -27,5 +27,5 @@ "vite": "^8.3.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 8402969c20..641618631d 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index de963120f4..d52baaa757 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index 97405221de..d9c64e6bac 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index f1fc7219c9..227f8b7a9f 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 841c979c14..31f25c5e8a 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index 5868285101..f5d5be5541 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 1c6ba084c1..0927a7388f 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -20,5 +20,5 @@ "devDependencies": { "playwright": "^1.63.0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 3e225d2f41..df5bc13b27 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 83d30f08a9..41337275df 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 90faf2acea..38eb1e0939 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index b77a35b327..f2bc2d696b 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 87546804f9..e4959293fa 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index f40741cf5e..23aeeb2ee5 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index aab41a3eaf..5badd11056 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1" } diff --git a/plugins/package.json b/plugins/package.json index f6b6d8901c..01d2a885b5 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index faf9371961..b84286c8f0 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d4f60051..2b8e0f539b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' diff --git a/render-wasm/package.json b/render-wasm/package.json index 96253a6356..f1f4b196ab 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 9ef64d85bd..a0180171fa 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' From 66f1f689f8f6064c0a0a2c916d6361e64b951776 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:09:35 +0100 Subject: [PATCH 193/325] :recycle: Use the DS select for the gradient type in the colorpicker (#12653) * :recycle: Use the DS select for the gradient type in the colorpicker The Linear / Radial select of the gradient options used the legacy select. It now uses select* with string ids, converted back to the gradient type keyword in the handler. Visible changes: the DS select look (label 1px higher, DS list with taller rows and the DS check mark, opening 2px lower). The list still opens inside the colorpicker, so it needs no portal. The "Create a RADIAL gradient" e2e test picks the type by role instead of a DOM position that only matched the legacy markup. New ds visual test "Gradient type select": closed, hover, open and changed. AI-assisted-by: claude-opus-5-5 * :recycle: Move colorpicker gradient styles off the deprecated styles gradients.scss now uses DS spacing, sizes, borders, typography and logical properties, and resolves the --menu-shadow-color alias. The deprecated import stays only for %input-element: the stop offset input moves to the DS with the numeric inputs group. The offset input uses body-small (line-height 1.3 instead of 1.4); the input keeps its height, so nothing moves. 0px difference against the previous commit, including a new shot of the whole gradient panel. gradients.cljs uses an icon-text class that has no rule in this file. AI-assisted-by: claude-opus-5-5 --- .../playwright/ui/specs/colorpicker.spec.js | 15 ++- .../ui/visual-specs/workspace.spec.js | 55 ++++++++++ .../ui/workspace/colorpicker/gradients.cljs | 20 ++-- .../ui/workspace/colorpicker/gradients.scss | 100 ++++++++++-------- 4 files changed, 126 insertions(+), 64 deletions(-) diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index ee9dbcd770..6625cb4b8a 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -144,15 +144,14 @@ test("Create a RADIAL gradient", async ({ page }) => { const gradOption = workspacePage.page.getByText("Gradient"); await gradOption.click(); - const gradTypeOptions = workspacePage.colorpicker - .locator("div") - .filter({ hasText: "Linear" }) - .nth(3); - await gradTypeOptions.click(); + const gradTypeSelect = workspacePage.colorpicker + .getByRole("combobox") + .filter({ hasText: "Linear" }); + await gradTypeSelect.click(); - const gradRadialOption = workspacePage.page - .locator("li") - .filter({ hasText: "Radial" }); + const gradRadialOption = workspacePage.page.getByRole("option", { + name: "Radial", + }); await gradRadialOption.click(); const addStopBtn = workspacePage.page.getByRole("button", { diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index 693d10ecff..d831b30a07 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -1249,3 +1249,58 @@ test.describe("Comments panel filter", () => { await expect(menu).toHaveScreenshot(shot); }); }); + +test.describe("Gradient type select", () => { + test("Shows the gradient type select closed, open and changed", async ({ + page, + }) => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC(/get\-file\?/, "workspace/get-file-not-empty.json"); + await workspace.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + await workspace.clickLeafLayer("Rectangle"); + await page.getByRole("button", { name: "#B1B2B5" }).click(); + await page.getByText("Solid").click(); + await page.getByText("Gradient").click(); + + const typeSelect = workspace.colorpicker + .getByRole("combobox") + .filter({ hasText: "Linear" }); + await expect(typeSelect).toBeVisible(); + const pickerBox = await workspace.colorpicker.boundingBox(); + const selectBox = await typeSelect.boundingBox(); + // The gradient bar above the select and room below it for the open list. + const clip = { + x: Math.floor(pickerBox.x), + y: Math.floor(selectBox.y) - 60, + width: Math.ceil(pickerBox.width), + height: 200, + }; + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await typeSelect.hover(); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await typeSelect.click(); + const radial = page.getByRole("option", { name: "Radial" }); + await expect(radial).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + await radial.click(); + await expect( + workspace.colorpicker.getByRole("combobox").filter({ hasText: "Radial" }), + ).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot({ clip, ...shot }); + + // The whole gradient panel: preview bar, stops and the stop list. + const panel = workspace.colorpicker.locator("[class*='gradient-panel']"); + await expect(panel).toHaveScreenshot(shot); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs index 8338544f73..e1817621c6 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs @@ -15,8 +15,8 @@ [app.main.features :as features] [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.reorder-handler :refer [reorder-handler*]] - [app.main.ui.components.select :refer [select]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] @@ -167,6 +167,10 @@ :on-focus handle-focus-stop-color :on-blur handle-blur-stop-color}]])) +(def ^:private gradient-type-options + [{:id "linear-gradient" :label "Linear"} + {:id "radial-gradient" :label "Radial"}]) + (mf/defc gradients* [{:keys [type stops @@ -200,9 +204,9 @@ handle-change-type (mf/use-callback (mf/deps on-change-type) - (fn [event] + (fn [type-id] (when on-change-type - (on-change-type event)))) + (on-change-type (keyword type-id))))) handle-add-stop (mf/use-callback @@ -336,12 +340,10 @@ :style {"--position" (dm/str (* offset 100) "%")}}]])]] [:div {:class (stl/css :gradient-options)} - [:& select - {:default-value type - :options [{:value :linear-gradient :label "Linear"} - {:value :radial-gradient :label "Radial"}] - :on-change handle-change-type - :class (stl/css :gradient-options-select)}] + [:> select* {:default-selected (d/name (d/nilv type :linear-gradient)) + :options gradient-type-options + :on-change handle-change-type + :wrapper-class (stl/css :gradient-options-select)}] [:div {:class (stl/css :gradient-options-buttons)} [:> icon-button* {:variant "ghost" diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index 0ce4019fd4..038e326ea4 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -4,18 +4,24 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL +// Only for %input-element: the stop offset input moves to the DS with the +// numeric inputs group. @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; .gradient-panel { - margin-top: deprecated.$s-12; + margin-block-start: var(--sp-m); display: grid; - gap: deprecated.$s-4; - grid-template-rows: deprecated.$s-56 deprecated.$s-32 1fr; + gap: var(--sp-xs); + grid-template-rows: px2rem(56) $sz-32 1fr; } .gradient-preview { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; display: flex; align-items: center; position: relative; @@ -23,19 +29,19 @@ } .gradient-background { - width: 100%; - height: deprecated.$s-20; - border-radius: deprecated.$br-4; + inline-size: 100%; + block-size: px2rem(20); + border-radius: $br-4; position: relative; cursor: pointer; } .gradient-preview-stop-wrapper { position: absolute; - width: calc(100% - deprecated.$s-24 - deprecated.$s-4); - height: 100%; - left: deprecated.$s-2; - top: calc(-1 * deprecated.$s-4); + inline-size: calc(100% - #{px2rem(28)}); + block-size: 100%; + inset-inline-start: px2rem(2); + inset-block-start: calc(-1 * var(--sp-xs)); pointer-events: none; } @@ -43,47 +49,47 @@ background-color: var(--color-foreground-primary); background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAADFJREFUOE9jZGBgEAFifOANPknGUQMYhkkYEEgG+NMJKAwIAbwJbdQABnBCIgRoG4gAIF8IsXB/Rs4AAAAASUVORK5CYII="); background-position: left center; - background-size: deprecated.$s-8; - border-radius: deprecated.$br-6; - border: deprecated.$s-2 solid var(--color-foreground-primary); - box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); - height: calc(deprecated.$s-24 - deprecated.$s-2); - left: var(--position); + background-size: px2rem(8); + border-radius: $br-6; + border: $b-2 solid var(--color-foreground-primary); + box-shadow: 0 0 px2rem(4) 0 var(--color-shadow-dark); + block-size: px2rem(22); + inset-inline-start: var(--position); overflow: hidden; pointer-events: initial; position: absolute; - width: calc(deprecated.$s-24 - deprecated.$s-2); + inline-size: px2rem(22); &.is-selected, &:hover { - outline: deprecated.$s-2 solid var(--color-accent-primary); + outline: $b-2 solid var(--color-accent-primary); } } .gradient-preview-stop-decoration { background: var(--color-foreground-primary); border-radius: 100%; - bottom: deprecated.$s-32; - box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); - height: deprecated.$s-4; - left: calc(var(--position) + deprecated.$s-8); + inset-block-end: var(--sp-xxxl); + box-shadow: 0 0 px2rem(4) 0 var(--color-shadow-dark); + block-size: px2rem(4); + inset-inline-start: calc(var(--position) + var(--sp-s)); position: absolute; - width: deprecated.$s-4; + inline-size: px2rem(4); } .gradient-preview-stop-color { position: absolute; - left: 0; - width: 50%; - height: 100%; + inset-inline-start: 0; + inline-size: 50%; + block-size: 100%; background: var(--color-solid); } .gradient-preview-stop-alpha { position: absolute; - left: 50%; - width: 50%; - height: 100%; + inset-inline-start: 50%; + inline-size: 50%; + block-size: 100%; background: var(--color-alpha); } @@ -98,7 +104,7 @@ } .gradient-options-select { - width: deprecated.$s-140; + inline-size: px2rem(140); } .rotate-icon { @@ -108,18 +114,18 @@ .gradient-stops-list { display: flex; flex-direction: column; - gap: deprecated.$s-4; - max-height: deprecated.$s-180; + gap: var(--sp-xs); + max-block-size: px2rem(180); overflow: hidden auto; padding: 0 0 var(--sp-s) var(--sp-m); } .gradient-stops-entry { display: flex; - gap: deprecated.$s-4; - padding: deprecated.$s-2; - border-radius: deprecated.$br-12; - border: deprecated.$s-1 solid transparent; + gap: var(--sp-xs); + padding: var(--sp-xxs); + border-radius: $br-12; + border: $b-1 solid transparent; position: relative; &.is-selected { @@ -141,26 +147,26 @@ .offset-input-wrapper { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - width: deprecated.$s-92; + inline-size: px2rem(92); } .gradient-separator { border-color: var(--color-background-quaternary); - border-width: deprecated.$s-3; - margin-left: calc(var(--sp-m) / 2); + border-width: px2rem(3); + margin-inline-start: calc(var(--sp-m) / 2); position: relative; - width: calc(100% - var(--sp-m)); + inline-size: calc(100% - var(--sp-m)); } .gradient-preview-stop-preview { background: var(--color-foreground-primary); border-radius: 50%; - height: deprecated.$s-4; - left: calc(var(--preview-position, 0%) - deprecated.$s-2); + block-size: px2rem(4); + inset-inline-start: calc(var(--preview-position, 0%) - #{px2rem(2)}); pointer-events: none; position: absolute; - top: calc(50% - deprecated.$s-2); - width: deprecated.$s-4; + inset-block-start: calc(50% - #{px2rem(2)}); + inline-size: px2rem(4); } From 755369276d2faf2e2f4c6294eaccd8e7837e3d54 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:09:46 +0100 Subject: [PATCH 194/325] :bug: Fix stack overflow when applying a library update (#12661) * :bug: Fix stack overflow when applying a library update Each shape updated by a component sync added its reg-objects undo change with (update :undo-changes concat [...]). That wrapped the whole undo list in one more lazy concat per shape, with the old list at the head. After a few thousand shapes, walking the list overflowed the stack, which matches the nested LazySeq frames reported when a library update is applied. add-update-attr-changes now conjs the reg-objects before the mod-obj, so undo restores each shape and then recalculates its parents, as redo does. The trailing reg-objects also recalculated groups after undoing shapes that the sync added or removed. To keep that, add-shape-to-instance, add-shape-to-main and remove-shape now add their own undo reg-objects, conjed before their other undo changes so it runs after them. Tests cover many calls to add-update-attr-changes, a sync and undo of many shapes, and undoing a sync that moves, adds or removes a child of a group in the copy. Closes #12576 AI-assisted-by: claude-opus-5-5 * :bug: Test undo of an inverse sync that adds a shape The previous commit added an undo :reg-objects to add-shape-to-main, so undoing an "update main component" recalculates the main's groups after removing the added shapes. No test covered it. The new test adds a shape at the start of a group in the copy and changes a sibling's fill, runs the inverse sync and undoes it. Without the undo reg, the main group keeps the bounds of the removed shape. AI-assisted-by: claude-opus-5-5 --- common/src/app/common/logic/libraries.cljc | 40 ++- .../common_tests/logic/comp_sync_test.cljc | 256 ++++++++++++++++++ 2 files changed, 287 insertions(+), 9 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index ac5a516156..f11596677e 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -1338,6 +1338,12 @@ container {:type :reg-objects :shapes all-parents})) + ;; Conj reg before the deletions, so undo recalculates the parents + ;; after removing the shapes. + changes' (update changes' :undo-changes conj (make-change + container + {:type :reg-objects + :shapes all-parents})) changes' (reduce del-obj-change changes' new-shapes)] (if (and (ctk/touched-group? parent-shape :shapes-group) omit-touched?) @@ -1444,6 +1450,12 @@ changes' (update changes' :redo-changes conj {:type :reg-objects :component-id (:id component) :shapes all-parents}) + ;; Conj reg before the other undo changes, so undo recalculates the + ;; parents after removing the shapes. + changes' (update changes' :undo-changes conj (make-change + component-container + {:type :reg-objects + :shapes all-parents})) changes' (reduce mod-obj-change changes' updated-shapes) changes' (reduce del-obj-change changes' new-shapes)] @@ -1491,6 +1503,12 @@ changes' (-> (reduce add-redo-change changes ids) (update :redo-changes conj (make-change + container + {:type :reg-objects + :shapes (vec parents)})) + ;; Conj reg before the additions, so undo recalculates + ;; the parents after restoring the shapes. + (update :undo-changes conj (make-change container {:type :reg-objects :shapes (vec parents)}))) @@ -1693,15 +1711,19 @@ container {:type :reg-objects :shapes all-parents})) - (update :undo-changes conj (make-change - container - {:type :mod-obj - :id (:id dest-shape) - :operations (vec uoperations)})) - (update :undo-changes concat [(make-change - container - {:type :reg-objects - :shapes all-parents})]) + ;; Conj reg before mod, so undo restores the shape and then its + ;; parents. Do not append with concat: it nests one lazy seq per + ;; shape and overflows the stack on big syncs. + (update :undo-changes conj + (make-change + container + {:type :reg-objects + :shapes all-parents}) + (make-change + container + {:type :mod-obj + :id (:id dest-shape) + :operations (vec uoperations)})) (pcb/apply-changes-local)))) diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index a929c7a616..02cd219779 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -18,6 +18,7 @@ [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] + [app.common.types.container :as ctn] [app.common.types.shape-tree :as ctst] [clojure.test :as t])) @@ -539,3 +540,258 @@ ;; A position-only change in the main component must not propagate to copies ;; and therefore must produce no redo-changes. (t/is (empty? (:redo-changes sync-changes))))) + +(defn- sync-file-changes + [file] + (cll/generate-sync-file-changes (pcb/empty-changes) + nil + :components + (:id file) + (thi/id :component1) + (:id file) + {(:id file) file} + (:id file))) + +(t/deftest test-sync-many-updated-shapes + ;; The undo changes of a sync used to grow one lazy level per updated + ;; shape (#12576). Overflowing the stack needs thousands of shapes, which + ;; is too slow here; test-update-attr-changes-many-calls covers that. + (let [;; ==== Setup + n 500 + child-labels (mapv #(keyword (str "main-child-" %)) (range n)) + file (-> (thf/sample-file :file1) + (tho/add-component-with-many-children-and-copy + :component1 + :main-root + child-labels + :copy-root + :main-child-params-list (repeat n {:fills (ths/sample-fills-color + :fill-color "#abcdef")}))) + page (thf/current-page file) + + ;; ==== Action + changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + (into #{} (map thi/id) child-labels) + (fn [shape] + (assoc shape :fills (ths/sample-fills-color :fill-color "#fabada"))) + (:objects page) + {}) + updated-file (thf/apply-changes file changes1) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-fill (fn [file] + (let [copy-root (ths/get-shape file :copy-root)] + (into #{} + (map #(-> (ths/get-shape-by-id file %) :fills first :fill-color)) + (:shapes copy-root))))] + + ;; ==== Check + (t/is (= #{"#fabada"} (copy-fill file'))) + (t/is (= #{"#abcdef"} (copy-fill file''))))) + +(t/deftest test-update-attr-changes-many-calls + ;; Regression: each call nested the undo list in one more lazy concat, so + ;; walking it after many calls overflowed the stack (#12576). + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-rect :rect1)) + page (thf/current-page file) + rect (ths/get-shape file :rect1) + roperations [{:type :set :attr :opacity :val 0.5 :ignore-touched true}] + uoperations (list {:type :set :attr :opacity :val 1 :ignore-touched true}) + + ;; ==== Action + changes (reduce (fn [changes _] + (#'cll/add-update-attr-changes changes rect page roperations uoperations)) + (pcb/empty-changes) + (range 50000)) + undo (:undo-changes changes)] + + ;; ==== Check + (t/is (= 100000 (count undo))) + ;; Undo restores the shape first and then recalculates its parents. + (t/is (= [:mod-obj :reg-objects] (map :type (take 2 undo)))))) + +(defn- geometry + [shape] + ;; Compare as doubles: a recalculated selrect has doubles where the + ;; original one may have longs. + {:selrect (update-vals (select-keys (:selrect shape) [:x :y :width :height]) double) + :points (mapv #(update-vals (select-keys % [:x :y]) double) (:points shape))}) + +(defn- add-group-component-with-copy + [file] + ;; Generated shape tree: + ;; :main-root [:name Frame1] # [Component :component1] + ;; :main-group [:name Group1] + ;; :main-child1 [:name Rect1] + ;; :main-child2 [:name Rect2] + ;; + ;; :copy-root [:name Frame1] #--> [Component :component1] :main-root + ;; :copy-group [:name Group1] ---> :main-group + ;; :copy-child1 [:name Rect1] ---> :main-child1 + ;; :copy-child2 [:name Rect2] ---> :main-child2 + (-> file + (tho/add-frame :main-root :x 0 :y 0 :width 100 :height 100) + (tho/add-group :main-group :parent-label :main-root :x 10 :y 10 :width 30 :height 10) + (ths/add-sample-shape :main-child1 :parent-label :main-group :name "Rect1" + :x 10 :y 10 :width 10 :height 10 + :fills (ths/sample-fills-color :fill-color "#abcdef")) + (ths/add-sample-shape :main-child2 :parent-label :main-group :name "Rect2" + :x 30 :y 10 :width 10 :height 10 + :fills (ths/sample-fills-color :fill-color "#abcdef")) + (thc/make-component :component1 :main-root) + (thc/instantiate-component :component1 :copy-root + :children-labels [:copy-group :copy-child1 :copy-child2]))) + +(defn- touch-copy-group-geometry + ;; With its geometry touched, the sync does not copy the group's own + ;; geometry from the main, so only the parents recalculation decides it. + [file] + (reduce #(ths/update-shape %1 %2 :touched #{:geometry-group}) + file + [:copy-group :copy-child1 :copy-child2])) + +(defn- update-fill + [file label color] + (let [page (thf/current-page file) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id label)} + #(assoc % :fills (ths/sample-fills-color :fill-color color)) + (:objects page) + {})] + (thf/apply-changes file changes))) + +(t/deftest test-sync-undo-restores-group-geometry + (let [;; ==== Setup + file (add-group-component-with-copy (thf/sample-file :file1)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + changes1 (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (cls/generate-update-shapes #{(thi/id :main-child2)} + #(gsh/absolute-move % (gpt/point 60 60)) + (:objects page) + {}) + (pcb/resize-parents [(thi/id :main-child2)])) + updated-file (thf/apply-changes file changes1) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-undo-group-after-add-and-update + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (touch-copy-group-geometry) + (ths/add-sample-shape :free-shape :x 60 :y 60 :width 10 :height 10)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + ;; The new shape goes first in the group, so the sync handles it before + ;; the updated sibling. + changes1 (cls/generate-relocate (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + (thi/id :main-group) + 0 + #{(thi/id :free-shape)}) + updated-file (-> (thf/apply-changes file changes1) + (update-fill :main-child1 "#fabada")) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (= 3 (count (:shapes copy-group')))) + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (:shapes copy-group) (:shapes copy-group''))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-undo-group-after-remove-and-update + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (touch-copy-group-geometry)) + page (thf/current-page file) + copy-group (ths/get-shape file :copy-group) + + ;; ==== Action + ;; The removed shape is the first one in the group, so the sync + ;; handles it before the updated sibling. Remove it only from the + ;; main, as a library update does, so that the sync removes it from + ;; the copy. + changes1 (-> (pcb/empty-changes nil (:id page)) + (pcb/with-objects (:objects page)) + (pcb/remove-objects #{(thi/id :main-child1)})) + updated-file (-> (thf/apply-changes file changes1) + (update-fill :main-child2 "#fabada")) + changes2 (sync-file-changes updated-file) + file' (thf/apply-changes updated-file changes2) + file'' (thf/apply-undo-changes file' changes2) + + ;; ==== Get + copy-group' (ths/get-shape file' :copy-group) + copy-group'' (ths/get-shape file'' :copy-group)] + + ;; ==== Check + (t/is (= 1 (count (:shapes copy-group')))) + (t/is (not= (geometry copy-group) (geometry copy-group'))) + (t/is (= (:shapes copy-group) (:shapes copy-group''))) + (t/is (= (geometry copy-group) (geometry copy-group''))))) + +(t/deftest test-sync-inverse-undo-group-after-add-and-update + (let [;; ==== Setup + ;; Penpot does not let users move shapes into a copy, but files can + ;; still hold copies with shapes the main lacks, so build the data + ;; directly. The new shape goes first in the copy group, so the + ;; inverse sync handles it before the updated sibling. + file (-> (thf/sample-file :file1) + (add-group-component-with-copy) + (ths/add-sample-shape :free-shape :parent-label :copy-group + :x 160 :y 160 :width 10 :height 10) + (ths/update-shape :copy-group :shapes [(thi/id :free-shape) + (thi/id :copy-child1) + (thi/id :copy-child2)])) + main-group (ths/get-shape file :main-group) + + ;; ==== Action + updated-file (update-fill file :copy-child1 "#fabada") + page (thf/current-page updated-file) + container (ctn/make-container page :page) + changes (-> (pcb/empty-changes) + (pcb/with-container container) + (cll/generate-sync-shape-inverse (:data updated-file) + {(:id updated-file) updated-file} + container + (thi/id :copy-root))) + file' (thf/apply-changes updated-file changes) + file'' (thf/apply-undo-changes file' changes) + + ;; ==== Get + main-group' (ths/get-shape file' :main-group) + main-group'' (ths/get-shape file'' :main-group)] + + ;; ==== Check + (t/is (= 3 (count (:shapes main-group')))) + (t/is (not= (geometry main-group) (geometry main-group'))) + (t/is (= (:shapes main-group) (:shapes main-group''))) + (t/is (= (geometry main-group) (geometry main-group''))))) From 12dd4d19d001d9c3af8f7ee35c483c68cc387d62 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:10:02 +0100 Subject: [PATCH 195/325] :recycle: Use a DS icon button for Share in the workspace header (#12681) * :recycle: Use a DS icon button for Share in the workspace header The Share link of the right header becomes an icon-button* with the DS share icon, like its neighbours (comments, history, viewer). It gets keyboard focus, a name and the DS tooltip and hover. It also makes Share visible again on develop: since the share icon moved to currentColor, the legacy icon drew with the transparent color of its svg. The button is 32px wide instead of 28px, so the zoom, comments and history controls move 4px to the left. AI-assisted-by: claude-opus-5-5 * :fire: Remove the share link styles from the workspace header Delete .share-btn and its svg rule, which no markup uses since Share is a DS icon button. refactor/ stays only for the menu placeholders. No visual change (0 px). AI-assisted-by: claude-opus-5-5 --- .../data/workspace/get-teams-shared.json | 25 ++++++++++++++++ .../ui/visual-specs/workspace.spec.js | 24 +++++++++++++++ .../app/main/ui/workspace/right_header.cljs | 10 +++---- .../app/main/ui/workspace/right_header.scss | 29 ++----------------- 4 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 frontend/playwright/data/workspace/get-teams-shared.json diff --git a/frontend/playwright/data/workspace/get-teams-shared.json b/frontend/playwright/data/workspace/get-teams-shared.json new file mode 100644 index 0000000000..2c6119c75f --- /dev/null +++ b/frontend/playwright/data/workspace/get-teams-shared.json @@ -0,0 +1,25 @@ +[ + { + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true + }, + "~:name": "Default", + "~:modified-at": "~m1713533116375", + "~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a", + "~:created-at": "~m1713533116375", + "~:is-default": false + } +] diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index d831b30a07..5441abf974 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -780,6 +780,30 @@ test.describe("Right header", () => { }); }); +test.describe("Share button", () => { + const shot = { maxDiffPixelRatio: 0, threshold: 0 }; + + test("Share button in the right header", async ({ page }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + // A shared team where the user is admin, so the header shows Share. + await workspace.mockRPC("get-teams", "workspace/get-teams-shared.json"); + await workspace.goToWorkspace(); + + const header = page.locator('[class*="workspace-header-right"]'); + const share = header + .locator("[class*='share-btn']") + .or(header.getByRole("button", { name: "Share" })); + await expect(share).toBeVisible(); + await page.mouse.move(640, 300); + await expect(header).toHaveScreenshot(shot); + + await share.hover(); + await page.waitForTimeout(1000); + await expect(header).toHaveScreenshot(shot); + }); +}); + test.describe("Undo history", () => { test("Shows the actions list with an entry expanded", async ({ page }) => { const workspace = new WorkspacePage(page); diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 0b2f9a9b0e..b76ac50fa9 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -25,7 +25,6 @@ [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.presence :refer [active-sessions*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -239,10 +238,11 @@ :on-click toggle-history}]]) (when display-share-button? - [:a {:class (stl/css :share-btn) - :title (tr "workspace.header.share") - :on-click open-share-dialog} - deprecated-icon/share]) + [:> icon-button* {:variant "action" + :tooltip-placement "bottom-left" + :aria-label (tr "workspace.header.share") + :icon i/share + :on-click open-share-dialog}]) [:> icon-button* {:variant "action" :tooltip-placement "bottom-left" diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 040b270490..a67b400537 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -9,8 +9,8 @@ @use "ds/_utils.scss" as *; @use "ds/typography.scss" as *; -// Only for the shared menu placeholders, and the button placeholders of -// the share link until the DS has a share icon. +// Only for the shared menu placeholders (%menu-dropdown, %menu-item-base, +// %shortcut-*), which move with the menus group. @use "refactor/common-refactor.scss" as deprecated; .workspace-header-right { @@ -120,31 +120,6 @@ position: relative; } -// Raw link until the DS has a share icon. -.share-btn { - @extend %button-tertiary; - - border-radius: $br-8; - margin: 0; - inline-size: $sz-28; - block-size: $sz-28; - border: none; - - // The legacy share icon has no class. - svg { - @extend %button-icon; - - block-size: $sz-16; - inline-size: $sz-16; - stroke: var(--color-foreground-secondary); - } - - &:hover { - background-color: transparent; - border: none; - } -} - .unread { position: absolute; inline-size: px2rem(8); From 0fa1077b9ac4329639f75a881694ddebb23e003b Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 29 Oct 2026 18:10:16 +0100 Subject: [PATCH 196/325] :bug: Render no type attribute on DS buttons used as links (#12709) * :bug: Render no type attribute on DS buttons used as links With `to`, button* renders an but still passed type="button". On an anchor, type is a MIME type hint, so "button" is invalid HTML. The type now applies only when the element is a + + Penpot MCP + + + +

Penpot MCP

+

The browser does not discard these tabs while they are connected to MCP.

+

Connected tabs

+
    + + + diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs new file mode 100644 index 0000000000..6d7b6b3069 --- /dev/null +++ b/mcp/packages/browser-extension/scripts/build.mjs @@ -0,0 +1,18 @@ +import { build } from "esbuild"; +import { cp, rm, writeFile } from "node:fs/promises"; +import { BROWSERS, createManifest } from "./manifest.mjs"; + +const TARGETS = { chrome: "chrome116", firefox: "firefox140" }; + +await rm("dist", { recursive: true, force: true }); +for (const browser of BROWSERS) { + const outdir = `dist/${browser}`; + const options = { bundle: true, target: TARGETS[browser], outdir, logLevel: "info" }; + // Chrome loads the service worker as a module, Firefox the event page as a classic script + await build({ ...options, entryPoints: ["src/background.ts"], format: browser === "chrome" ? "esm" : "iife" }); + await build({ ...options, entryPoints: ["src/popup.ts"], format: "esm" }); + // content scripts load as classic scripts + await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" }); + await cp("public", outdir, { recursive: true }); + await writeFile(`${outdir}/manifest.json`, JSON.stringify(createManifest(browser), null, 4) + "\n"); +} diff --git a/mcp/packages/browser-extension/scripts/manifest.mjs b/mcp/packages/browser-extension/scripts/manifest.mjs new file mode 100644 index 0000000000..5b11972c55 --- /dev/null +++ b/mcp/packages/browser-extension/scripts/manifest.mjs @@ -0,0 +1,61 @@ +/** Browsers the extension is built for, one directory of `dist/` each. */ +export const BROWSERS = ["chrome", "firefox"]; + +const ICONS = { + 16: "icons/icon-16.png", + 32: "icons/icon-32.png", + 48: "icons/icon-48.png", + 128: "icons/icon-128.png", +}; + +const CONTENT_SCRIPTS = [ + { + matches: ["https://design.penpot.app/*", "https://*.penpot.dev/*", "https://localhost/*", "http://localhost/*"], + js: ["content.js"], + run_at: "document_idle", + }, +]; + +/** + * Creates the extension manifest for a browser. + * + * Chrome runs the background as a module service worker. Firefox runs it as an event page and + * needs an add-on ID. + * + * @param {"chrome" | "firefox"} browser - The browser to build for + */ +export function createManifest(browser) { + const common = { + manifest_version: 3, + name: "Penpot MCP", + version: "0.1.0", + description: "Keeps the browser from discarding Penpot tabs connected to MCP.", + icons: ICONS, + permissions: ["storage"], + action: { + default_title: "Penpot MCP", + default_popup: "popup.html", + default_icon: { 16: ICONS[16], 32: ICONS[32] }, + }, + content_scripts: CONTENT_SCRIPTS, + }; + if (browser === "chrome") { + return { + ...common, + minimum_chrome_version: "116", + background: { service_worker: "background.js", type: "module" }, + }; + } + return { + ...common, + background: { scripts: ["background.js"] }, + browser_specific_settings: { + gecko: { + id: "mcp-extension@penpot.app", + // the first version that knows data_collection_permissions + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }, + }; +} diff --git a/mcp/packages/browser-extension/src/DiscardGuard.ts b/mcp/packages/browser-extension/src/DiscardGuard.ts new file mode 100644 index 0000000000..a50b06d682 --- /dev/null +++ b/mcp/packages/browser-extension/src/DiscardGuard.ts @@ -0,0 +1,28 @@ +import type { SessionChange, SessionMapObserver } from "./SessionMap.ts"; + +/** Browser control over the automatic discarding of tabs. */ +export interface DiscardControl { + setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise; +} + +/** + * Keeps the browser from discarding tabs that hold an MCP session, since a discarded tab reloads + * and loses its MCP connection. Tabs regain automatic discarding when they stop holding a session. + */ +export class DiscardGuard implements SessionMapObserver { + constructor(private readonly control: DiscardControl) {} + + sessionsChanged(change: SessionChange): void { + for (const tabId of change.tabsAdded) { + this.set(tabId, false); + } + for (const tabId of change.tabsRemoved) { + this.set(tabId, true); + } + } + + private set(tabId: number, autoDiscardable: boolean): void { + // the tab may have been closed meanwhile + this.control.setAutoDiscardable(tabId, autoDiscardable).catch(() => {}); + } +} diff --git a/mcp/packages/browser-extension/src/PageBridge.test.ts b/mcp/packages/browser-extension/src/PageBridge.test.ts new file mode 100644 index 0000000000..afaeabd048 --- /dev/null +++ b/mcp/packages/browser-extension/src/PageBridge.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import type { ContentMessage } from "./messages.ts"; +import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts"; + +const ORIGIN = "https://design.penpot.app"; + +/** Page recording what the bridge posts and dispatching events to it. */ +class FakePage implements PageWindow { + readonly location = { origin: ORIGIN }; + readonly posted: { message: unknown; targetOrigin: string }[] = []; + private readonly listeners = new Map void)[]>(); + + postMessage(message: unknown, targetOrigin: string): void { + this.posted.push({ message, targetOrigin }); + } + + addEventListener(type: string, listener: (event: any) => void): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + dispatch(type: string, event: object = {}): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + /** Posts a message on the page as the Penpot app does. */ + post(data: unknown, origin = ORIGIN, source: unknown = this): void { + this.dispatch("message", { data, origin, source }); + } +} + +/** Worker channel recording what the bridge sends. */ +class FakeWorker implements WorkerChannel { + readonly received: ContentMessage[] = []; + + async sendMessage(message: ContentMessage): Promise { + this.received.push(message); + return undefined; + } +} + +const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" }; + +let page: FakePage; +let worker: FakeWorker; + +beforeEach(() => { + page = new FakePage(); + worker = new FakeWorker(); + new PageBridge(page, worker).start(); +}); + +test("asks the page for its session on start, restricted to the page's origin", () => { + assert.deepEqual(page.posted, [{ message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }]); +}); + +test("says hello again when the page reports it has started listening", () => { + page.post({ type: "penpot-mcp:ready" }); + page.post({ type: "penpot-mcp:ready" }, "https://evil.example"); + assert.equal(page.posted.length, 2); + assert.deepEqual(page.posted[1], { message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }); +}); + +test("forwards the page's session announcements to the worker", () => { + page.post(announcement); + assert.deepEqual(worker.received, [{ type: "session", announcement }]); +}); + +test("ignores announcements from other origins or other windows", () => { + page.post(announcement, "https://evil.example"); + page.post(announcement, ORIGIN, {}); + assert.deepEqual(worker.received, []); +}); + +test("ignores invalid announcements and its own hello", () => { + page.post({ ...announcement, status: "unknown" }); + page.post({ type: "penpot-mcp-extension:hello" }); + assert.deepEqual(worker.received, []); +}); + +test("reports a hidden page and asks again when it returns from the back/forward cache", () => { + page.dispatch("pagehide"); + page.dispatch("pageshow", { persisted: false }); + assert.equal(page.posted.length, 1); + page.dispatch("pageshow", { persisted: true }); + assert.deepEqual(worker.received, [{ type: "page-hidden" }]); + assert.equal(page.posted.length, 2); +}); + +test("keeps working when the worker cannot be reached", () => { + const failing = new FakePage(); + new PageBridge(failing, { + sendMessage: () => { + throw new Error("Extension context invalidated."); + }, + }).start(); + failing.post(announcement); + new PageBridge(failing, { + sendMessage: () => Promise.reject(new Error("No receiver")), + }).start(); + failing.post(announcement); +}); diff --git a/mcp/packages/browser-extension/src/PageBridge.ts b/mcp/packages/browser-extension/src/PageBridge.ts new file mode 100644 index 0000000000..42a0a54887 --- /dev/null +++ b/mcp/packages/browser-extension/src/PageBridge.ts @@ -0,0 +1,69 @@ +import type { ExtensionHello } from "@penpot/mcp-common"; +import { parseAnnouncement, type ContentMessage } from "./messages.ts"; + +/** Page operations the bridge uses; the content script passes its window. */ +export interface PageWindow { + readonly location: { readonly origin: string }; + postMessage(message: unknown, targetOrigin: string): void; + addEventListener(type: string, listener: (event: any) => void): void; +} + +/** Channel from the content script to the service worker. */ +export interface WorkerChannel { + sendMessage(message: ContentMessage): Promise; +} + +/** + * Relays the MCP session announced by a Penpot page to the extension's service worker. + * + * The bridge asks the page for its session on load, when the page reports it has started + * listening, and when the page returns from the back/forward cache. It accepts only messages the + * page posted on its own window from its own origin. + */ +export class PageBridge { + constructor( + private readonly page: PageWindow, + private readonly worker: WorkerChannel + ) {} + + /** Starts listening to the page and asks it for its session. */ + start(): void { + this.page.addEventListener("message", (event: MessageEvent) => this.handleMessage(event)); + this.page.addEventListener("pagehide", () => this.send({ type: "page-hidden" })); + this.page.addEventListener("pageshow", (event: PageTransitionEvent) => { + if (event.persisted) { + this.sayHello(); + } + }); + this.sayHello(); + } + + private sayHello(): void { + const hello: ExtensionHello = { type: "penpot-mcp-extension:hello" }; + this.page.postMessage(hello, this.page.location.origin); + } + + private handleMessage(event: MessageEvent): void { + if (event.source !== this.page || event.origin !== this.page.location.origin) { + return; + } + // the page started listening after the hello sent on load + if (event.data?.type === "penpot-mcp:ready") { + this.sayHello(); + return; + } + const announcement = parseAnnouncement(event.data); + if (announcement) { + this.send({ type: "session", announcement }); + } + } + + private send(message: ContentMessage): void { + try { + // the worker cannot be reached after the extension is reloaded or removed + this.worker.sendMessage(message).catch(() => {}); + } catch { + // ignored for the same reason + } + } +} diff --git a/mcp/packages/browser-extension/src/PopupState.test.ts b/mcp/packages/browser-extension/src/PopupState.test.ts new file mode 100644 index 0000000000..94a1b9d892 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getPopupState } from "./PopupState.ts"; +import { SessionMap } from "./SessionMap.ts"; + +test("lists the sessions and their tabs", async () => { + const sessions = new SessionMap({ load: async () => undefined, save: async () => {} }); + await sessions.announce( + { tabId: 7, windowId: 1 }, + { type: "penpot-mcp:session", sessionId: "tab", status: "connected" } + ); + assert.deepEqual(getPopupState(sessions), { sessions: [{ sessionId: "tab", tabId: 7 }] }); +}); diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts new file mode 100644 index 0000000000..1bf6678a69 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.ts @@ -0,0 +1,22 @@ +import type { SessionMap } from "./SessionMap.ts"; + +/** Request of the popup for the extension's state. */ +export interface PopupStateRequest { + type: "popup:get-state"; +} + +/** State of the extension shown in the popup. */ +export interface PopupState { + sessions: { sessionId: string; tabId: number }[]; +} + +/** + * Collects the state shown in the popup. + * + * @param sessions - The map of sessions to tabs + */ +export function getPopupState(sessions: SessionMap): PopupState { + return { + sessions: sessions.all().map(([sessionId, entry]) => ({ sessionId, tabId: entry.tabId })), + }; +} diff --git a/mcp/packages/browser-extension/src/SessionMap.test.ts b/mcp/packages/browser-extension/src/SessionMap.test.ts new file mode 100644 index 0000000000..38e31fa874 --- /dev/null +++ b/mcp/packages/browser-extension/src/SessionMap.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import type { SessionAnnouncement } from "@penpot/mcp-common"; +import { DiscardGuard } from "./DiscardGuard.ts"; +import { SessionMap, type SessionChange, type SessionEntry, type SessionStore } from "./SessionMap.ts"; + +/** In-memory store standing in for session storage. */ +class MemoryStore implements SessionStore { + constructor(public saved?: Record) {} + + async load() { + return this.saved; + } + + async save(entries: Record) { + this.saved = structuredClone(entries); + } +} + +const TAB = { tabId: 1, windowId: 10 }; +const OTHER_TAB = { tabId: 2, windowId: 10 }; + +function announcement( + status: SessionAnnouncement["status"], + sessionId: string | null = "session" +): SessionAnnouncement { + return { type: "penpot-mcp:session", sessionId, status }; +} + +let store: MemoryStore; +let changes: SessionChange[]; +let sessions: SessionMap; + +beforeEach(() => { + store = new MemoryStore(); + changes = []; + sessions = new SessionMap(store); + sessions.observe({ sessionsChanged: (change) => changes.push(change) }); +}); + +test("maps a connected session to the announcing tab and saves it", async () => { + await sessions.announce(TAB, announcement("connected")); + assert.deepEqual(sessions.get("session"), TAB); + assert.deepEqual(store.saved, { session: TAB }); + assert.deepEqual(changes, [{ tabsAdded: [1], tabsRemoved: [] }]); +}); + +test("ignores announcements without a connected session", async () => { + await sessions.announce(TAB, announcement("connecting", null)); + await sessions.announce(TAB, announcement("connected", null)); + await sessions.announce(TAB, announcement("error")); + assert.deepEqual(sessions.all(), []); + assert.deepEqual(changes, []); +}); + +test("keeps the session while the plugin reconnects", async () => { + await sessions.announce(TAB, announcement("connected")); + await sessions.announce(TAB, announcement("connecting")); + await sessions.announce(TAB, announcement("connected")); + assert.equal(sessions.get("session")?.tabId, 1); + assert.equal(changes.length, 1); +}); + +test("forgets the tab's session when its page announces a disconnect", async () => { + await sessions.announce(TAB, announcement("connected")); + await sessions.announce(TAB, announcement("disconnected", null)); + assert.equal(sessions.get("session"), undefined); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] }); +}); + +test("a tab holds only the session it announced last", async () => { + await sessions.announce(TAB, announcement("connected", "old")); + await sessions.announce(TAB, announcement("connected", "new")); + assert.equal(sessions.get("old"), undefined); + assert.equal(sessions.get("new")?.tabId, 1); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [] }); +}); + +test("forgets only the sessions of a removed tab", async () => { + await sessions.announce(TAB, announcement("connected", "first")); + await sessions.announce(OTHER_TAB, announcement("connected", "second")); + await sessions.removeTab(1); + assert.deepEqual( + sessions.all().map(([sessionId]) => sessionId), + ["second"] + ); + assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] }); +}); + +test("restores the saved sessions when the worker restarts", async () => { + await sessions.announce(TAB, announcement("connected")); + const restarted = new SessionMap(store); + await restarted.ready(); + assert.equal(restarted.get("session")?.tabId, 1); +}); + +test("changes made while restoring apply to the restored sessions", async () => { + const saved = new MemoryStore({ first: TAB }); + const restored = new SessionMap(saved); + await restored.announce(OTHER_TAB, announcement("connected", "second")); + assert.deepEqual(Object.keys(saved.saved!).sort(), ["first", "second"]); +}); + +test("the discard guard protects tabs holding a session and releases them after", async () => { + const calls: [number, boolean][] = []; + const guard = new DiscardGuard({ + setAutoDiscardable: async (tabId, autoDiscardable) => { + calls.push([tabId, autoDiscardable]); + }, + }); + const guarded = new SessionMap(new MemoryStore()); + guarded.observe(guard); + await guarded.announce(TAB, announcement("connected")); + await guarded.removeTab(1); + assert.deepEqual(calls, [ + [1, false], + [1, true], + ]); +}); + +test("the discard guard ignores tabs closed meanwhile", async () => { + const guard = new DiscardGuard({ setAutoDiscardable: () => Promise.reject(new Error("No tab with id")) }); + guard.sessionsChanged({ tabsAdded: [1], tabsRemoved: [2] }); + await new Promise((resolve) => setImmediate(resolve)); +}); diff --git a/mcp/packages/browser-extension/src/SessionMap.ts b/mcp/packages/browser-extension/src/SessionMap.ts new file mode 100644 index 0000000000..53eca8e1e2 --- /dev/null +++ b/mcp/packages/browser-extension/src/SessionMap.ts @@ -0,0 +1,129 @@ +import type { SessionAnnouncement } from "@penpot/mcp-common"; + +/** The tab holding an MCP session. */ +export interface SessionEntry { + tabId: number; + windowId: number; +} + +/** Persistence of the session map across service worker restarts. */ +export interface SessionStore { + load(): Promise | undefined>; + save(entries: Record): Promise; +} + +/** Change of the tabs holding sessions, reported after each change of the map. */ +export interface SessionChange { + /** tabs that hold a session and held none before. */ + tabsAdded: number[]; + /** tabs that held a session and hold none now. */ + tabsRemoved: number[]; +} + +/** Receiver of the changes of a session map. */ +export interface SessionMapObserver { + sessionsChanged(change: SessionChange): void; +} + +/** + * Map from MCP session IDs to the tabs holding them. + * + * A tab holds the session it last announced as connected, until it announces a disconnect, is + * hidden or is closed. The map is saved after each change and restored when the service worker + * restarts. + */ +export class SessionMap { + private entries = new Map(); + private readonly observers: SessionMapObserver[] = []; + private readonly loaded: Promise; + + constructor(private readonly store: SessionStore) { + this.loaded = store.load().then( + (saved) => { + this.entries = new Map(Object.entries(saved ?? {})); + }, + () => {} + ); + } + + /** Adds a receiver of the changes of this map. */ + observe(observer: SessionMapObserver): void { + this.observers.push(observer); + } + + /** Resolves once the saved entries have been restored. */ + ready(): Promise { + return this.loaded; + } + + /** + * Records a session announcement made by the page in a tab. + * + * @param tab - The tab and window of the announcing page + * @param announcement - The page's announcement + */ + async announce(tab: { tabId: number; windowId: number }, announcement: SessionAnnouncement): Promise { + await this.loaded; + if (announcement.status === "disconnected") { + await this.change(() => this.deleteTab(tab.tabId)); + } else if (announcement.status === "connected" && announcement.sessionId) { + const { sessionId } = announcement; + await this.change(() => { + // a tab holds a single session + this.deleteTab(tab.tabId); + this.entries.set(sessionId, { ...tab }); + }); + } + } + + /** + * Forgets the sessions held by a tab. + * + * @param tabId - The tab whose page was hidden, or which was closed or replaced + */ + async removeTab(tabId: number): Promise { + await this.loaded; + await this.change(() => this.deleteTab(tabId)); + } + + /** Returns the entry of a session, if a tab holds it. */ + get(sessionId: string): SessionEntry | undefined { + return this.entries.get(sessionId); + } + + /** Returns all sessions with their entries. */ + all(): [string, SessionEntry][] { + return [...this.entries]; + } + + /** Applies a change, then saves the map and reports the change if any tab gained or lost a session. */ + private async change(apply: () => void): Promise { + const before = this.tabs(); + const snapshot = JSON.stringify([...this.entries]); + apply(); + if (JSON.stringify([...this.entries]) === snapshot) { + return; + } + const after = this.tabs(); + await this.store.save(Object.fromEntries(this.entries)); + const change = { + tabsAdded: [...after].filter((tabId) => !before.has(tabId)), + tabsRemoved: [...before].filter((tabId) => !after.has(tabId)), + }; + for (const observer of this.observers) { + observer.sessionsChanged(change); + } + } + + private deleteTab(tabId: number): void { + for (const [sessionId, entry] of [...this.entries]) { + if (entry.tabId === tabId) { + this.entries.delete(sessionId); + } + } + } + + private tabs(): Set { + return new Set([...this.entries.values()].map((entry) => entry.tabId)); + } +} diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts new file mode 100644 index 0000000000..fc4f6ddf43 --- /dev/null +++ b/mcp/packages/browser-extension/src/background.ts @@ -0,0 +1,44 @@ +/** + * Service worker of the Penpot MCP extension. + * + * It keeps the map from MCP sessions to the tabs holding them, and keeps the browser from + * discarding those tabs. + * + * Chrome may stop the worker, and Firefox its event page, at any time, so listeners are + * registered synchronously at start and state lives in session storage. + */ +import { ChromeDiscardControl, ChromeSessionStore } from "./chrome.ts"; +import { DiscardGuard } from "./DiscardGuard.ts"; +import { parseAnnouncement, type ContentMessage } from "./messages.ts"; +import { getPopupState, type PopupStateRequest } from "./PopupState.ts"; +import { SessionMap } from "./SessionMap.ts"; + +const sessions = new SessionMap(new ChromeSessionStore()); +sessions.observe(new DiscardGuard(new ChromeDiscardControl())); + +chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateRequest, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) { + return; + } + const tab = sender.tab; + if (message?.type === "popup:get-state" && !tab) { + void sessions.ready().then(() => sendResponse(getPopupState(sessions))); + // the response is sent asynchronously + return true; + } + if (tab?.id === undefined) { + return; + } + if (message?.type === "page-hidden") { + void sessions.removeTab(tab.id); + } else if (message?.type === "session") { + const announcement = parseAnnouncement(message.announcement); + if (announcement) { + void sessions.announce({ tabId: tab.id, windowId: tab.windowId }, announcement); + } + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => void sessions.removeTab(tabId)); + +chrome.tabs.onReplaced.addListener((_addedTabId, removedTabId) => void sessions.removeTab(removedTabId)); diff --git a/mcp/packages/browser-extension/src/chrome.ts b/mcp/packages/browser-extension/src/chrome.ts new file mode 100644 index 0000000000..38954c7ced --- /dev/null +++ b/mcp/packages/browser-extension/src/chrome.ts @@ -0,0 +1,26 @@ +import type { DiscardControl } from "./DiscardGuard.ts"; +import type { SessionEntry, SessionStore } from "./SessionMap.ts"; + +const SESSIONS_KEY = "sessions"; + +/** + * Session store in `chrome.storage.session`, which lives in memory, survives service worker + * restarts and clears when the browser closes. + */ +export class ChromeSessionStore implements SessionStore { + async load(): Promise | undefined> { + const stored = await chrome.storage.session.get(SESSIONS_KEY); + return stored[SESSIONS_KEY] as Record | undefined; + } + + async save(entries: Record): Promise { + await chrome.storage.session.set({ [SESSIONS_KEY]: entries }); + } +} + +/** Discard control through the Chrome tabs API. */ +export class ChromeDiscardControl implements DiscardControl { + async setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise { + await chrome.tabs.update(tabId, { autoDiscardable }); + } +} diff --git a/mcp/packages/browser-extension/src/content.ts b/mcp/packages/browser-extension/src/content.ts new file mode 100644 index 0000000000..1e115228a4 --- /dev/null +++ b/mcp/packages/browser-extension/src/content.ts @@ -0,0 +1,3 @@ +import { PageBridge } from "./PageBridge.ts"; + +new PageBridge(window, chrome.runtime).start(); diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts new file mode 100644 index 0000000000..f78006c58a --- /dev/null +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { access } from "node:fs/promises"; +import test from "node:test"; +import { BROWSERS, createManifest } from "../scripts/manifest.mjs"; + +const manifests = BROWSERS.map((browser) => createManifest(browser)); +const [chrome, firefox] = manifests; + +test("runs the background as a module service worker in Chrome and as an event page in Firefox", () => { + assert.deepEqual(chrome.background, { service_worker: "background.js", type: "module" }); + assert.deepEqual(firefox.background, { scripts: ["background.js"] }); +}); + +test("requests only storage", () => { + for (const manifest of manifests) { + assert.equal(manifest.manifest_version, 3); + assert.deepEqual(manifest.permissions, ["storage"]); + assert.equal(manifest.optional_permissions, undefined); + assert.equal(manifest.host_permissions, undefined); + } +}); + +test("gives the Firefox build an add-on ID and a minimum version", () => { + assert.deepEqual(firefox.browser_specific_settings, { + gecko: { + id: "mcp-extension@penpot.app", + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }); + assert.equal(chrome.browser_specific_settings, undefined); +}); + +test("references icons and a popup that exist in public/", async () => { + for (const manifest of manifests) { + const paths = [ + ...Object.values(manifest.icons as Record), + ...Object.values(manifest.action.default_icon as Record), + manifest.action.default_popup, + ]; + for (const path of paths) { + await access(new URL(`../public/${path}`, import.meta.url)); + } + } +}); diff --git a/mcp/packages/browser-extension/src/messages.test.ts b/mcp/packages/browser-extension/src/messages.test.ts new file mode 100644 index 0000000000..14040124e5 --- /dev/null +++ b/mcp/packages/browser-extension/src/messages.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseAnnouncement } from "./messages.ts"; + +const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" }; + +test("accepts session announcements, with or without a session ID", () => { + for (const data of [announcement, { ...announcement, sessionId: null, status: "connecting" }]) { + assert.deepEqual(parseAnnouncement(data), data); + } +}); + +test("keeps only the announcement fields", () => { + assert.deepEqual(parseAnnouncement({ ...announcement, extra: 1 }), announcement); +}); + +test("rejects other messages and malformed announcements", () => { + for (const data of [ + null, + "penpot-mcp:session", + { ...announcement, type: "penpot-mcp-extension:hello" }, + { ...announcement, sessionId: "" }, + { ...announcement, sessionId: 42 }, + { ...announcement, status: "frozen" }, + ]) { + assert.equal(parseAnnouncement(data), null, JSON.stringify(data)); + } +}); diff --git a/mcp/packages/browser-extension/src/messages.ts b/mcp/packages/browser-extension/src/messages.ts new file mode 100644 index 0000000000..e55644973e --- /dev/null +++ b/mcp/packages/browser-extension/src/messages.ts @@ -0,0 +1,32 @@ +import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-common"; + +/** + * Message sent by the content script to the service worker. + * + * - `session`: the page announced its MCP session + * - `page-hidden`: the page is being unloaded or stored in the back/forward cache + */ +export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" }; + +const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"]; + +function isSessionId(value: unknown): value is string | null { + return value === null || (typeof value === "string" && value.length > 0 && value.length <= 128); +} + +/** + * Validates a session announcement posted on the page. + * + * @param data - The data of a window message + * @returns The announcement, or null if the data is not a valid announcement + */ +export function parseAnnouncement(data: unknown): SessionAnnouncement | null { + if (typeof data !== "object" || data === null) { + return null; + } + const { type, sessionId, status } = data as Record; + if (type !== "penpot-mcp:session" || !isSessionId(sessionId) || !STATUSES.includes(status as McpConnectionStatus)) { + return null; + } + return { type, sessionId, status: status as McpConnectionStatus }; +} diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts new file mode 100644 index 0000000000..fa29fac3fb --- /dev/null +++ b/mcp/packages/browser-extension/src/popup.ts @@ -0,0 +1,28 @@ +import type { PopupState, PopupStateRequest } from "./PopupState.ts"; + +const REFRESH_INTERVAL_MS = 1_000; + +const sessionsList = document.getElementById("sessions") as HTMLUListElement; + +function render(state: PopupState): void { + const items = state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`); + sessionsList.replaceChildren( + ...(items.length ? items : ["No Penpot tab is connected to MCP."]).map((text) => { + const item = document.createElement("li"); + item.textContent = text; + item.classList.toggle("empty", items.length === 0); + return item; + }) + ); +} + +async function refresh(): Promise { + const request: PopupStateRequest = { type: "popup:get-state" }; + const state: PopupState | undefined = await chrome.runtime.sendMessage(request); + if (state) { + render(state); + } +} + +void refresh(); +setInterval(() => void refresh(), REFRESH_INTERVAL_MS); diff --git a/mcp/packages/browser-extension/tsconfig.json b/mcp/packages/browser-extension/tsconfig.json new file mode 100644 index 0000000000..b05f0b7842 --- /dev/null +++ b/mcp/packages/browser-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "types": ["chrome"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/*.test.ts"] +} diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index f9d7cde400..5168594f54 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -97,3 +97,36 @@ export interface ExecuteCodeTaskResultData { */ log: string; } + +/** + * Status of the Penpot MCP plugin connection, as reported to the Penpot app. + */ +export type McpConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error"; + +/** + * Message posted by a browser extension's content script to ask the Penpot page for its MCP session. + */ +export interface ExtensionHello { + type: "penpot-mcp-extension:hello"; +} + +/** + * Message posted by the Penpot page when it starts listening for an {@link ExtensionHello}. + * + * A content script that said hello before the page was listening says it again. + */ +export interface PageReady { + type: "penpot-mcp:ready"; +} + +/** + * Message posted by the Penpot page to announce its MCP session to a browser extension. + * + * The page sends it in reply to {@link ExtensionHello} and on every connection status change. + */ +export interface SessionAnnouncement { + type: "penpot-mcp:session"; + /** the MCP session ID of the page, or null while it is not known. */ + sessionId: string | null; + status: McpConnectionStatus; +} diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 3131086153..1ae436afd6 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -147,6 +147,17 @@ test("resume and freeze events cannot send liveness messages before initializati assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); }); +test("answers heartbeat requests once initialized, without forwarding them to the plugin", () => { + const ui = pluginUi(); + ui.connect(); + ui.sockets[0].receive({ type: "heartbeat-request" }); + assert.deepEqual(ui.sockets[0].sent, []); + ui.sockets[0].receive({ type: "initialized" }); + ui.sockets[0].receive({ type: "heartbeat-request" }); + assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]); + assert.equal(ui.messages.filter((message) => message.type === "heartbeat-request").length, 0); +}); + test("plugin reads the current file for each connection metadata request", () => { let onMessage!: (message: any) => void; const messages: any[] = []; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 71973ade0b..8ae1163ad6 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -145,7 +145,9 @@ function sendTaskResponse(response: any): void { * Emits a liveness signal from the plugin event loop. * * WebSocket ping/pong is not enough here: browsers can answer protocol pings while - * page JavaScript is frozen and unable to run MCP tasks. + * page JavaScript is frozen and unable to run MCP tasks. The plugin sends one on a timer and + * in answer to each heartbeat request of the server, since a browser runs the timers of a + * hidden page as rarely as once a minute but handles socket messages at once. */ function sendHeartbeat(): boolean { if (connectionReady && ws?.readyState === WebSocket.OPEN) { @@ -252,6 +254,10 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { parent.postMessage({ type: "context-request" }, "*"); return; } + if (request.type === "heartbeat-request") { + sendHeartbeat(); + return; + } // Track the current task received from the MCP server if (request.task) { updateCurrentTask(request.task); diff --git a/mcp/packages/server/src/PenpotConnection.ts b/mcp/packages/server/src/PenpotConnection.ts index 267c1b159b..902f248a48 100644 --- a/mcp/packages/server/src/PenpotConnection.ts +++ b/mcp/packages/server/src/PenpotConnection.ts @@ -17,6 +17,8 @@ export interface PenpotConnection extends PluginLivenessState { readonly userToken: string | null; readonly session: PenpotSession; readonly pingInterval: NodeJS.Timeout; + /** the interval sending heartbeat requests to the plugin. */ + readonly heartbeatRequestInterval: NodeJS.Timeout; /** whether initialization and dispatch subscriptions have completed. */ ready: boolean; } diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts index 1a42241169..3e676725f2 100644 --- a/mcp/packages/server/src/PluginBridge.sessions.test.ts +++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { once } from "node:events"; -import { afterEach, beforeEach, test } from "node:test"; +import { afterEach, beforeEach, mock, test } from "node:test"; import { WebSocket } from "ws"; -import { PluginBridge } from "./PluginBridge"; +import { HEARTBEAT_REQUEST_INTERVAL_MS, PluginBridge } from "./PluginBridge"; import { PluginTask } from "./PluginTask"; import type { PenpotMcpServer } from "./PenpotMcpServer"; @@ -37,17 +37,33 @@ async function connect(sessionId: string, token = "alice", fileName = "Design", const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=${token}`); sockets.push(socket); await once(socket, "open"); + // answer tasks from the start, since one may arrive right behind the initialization + socket.on("message", (raw) => { + const request = JSON.parse(raw.toString()); + if (request.type !== "initialized" && request.type !== "heartbeat-request") { + socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); + } + }); const initialized = once(socket, "message", { signal: AbortSignal.timeout(1500) }); socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file-1", fileName } })); const [message] = await initialized; assert.equal(JSON.parse(message.toString()).type, "initialized"); - socket.on("message", (raw) => { - const request = JSON.parse(raw.toString()); - socket.send(JSON.stringify({ id: request.id, success: true, data: reply })); - }); return socket; } +test("asks each plugin for a heartbeat at a fixed interval", async () => { + mock.timers.enable({ apis: ["setInterval"] }); + try { + const socket = await connect("tab-1"); + const requested = once(socket, "message", { signal: AbortSignal.timeout(1500) }); + mock.timers.tick(HEARTBEAT_REQUEST_INTERVAL_MS); + const [message] = await requested; + assert.deepEqual(JSON.parse(message.toString()), { type: "heartbeat-request" }); + } finally { + mock.timers.reset(); + } +}); + test("keeps two tabs for the same file and dispatches to the selected session", async () => { await connect("tab-1"); await connect("tab-2"); @@ -169,3 +185,21 @@ test("a dashboard session remains routable while its workspace metadata changes" assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]); } }); + +test("a task fails at once when its plugin connection closes before answering", async () => { + const plugin = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`); + sockets.push(plugin); + await once(plugin, "open"); + const initialized = once(plugin, "message"); + plugin.send(JSON.stringify({ type: "initialize", session: { sessionId: "tab", fileId: null, fileName: null } })); + await initialized; + // the socket closes once the task reaches it + plugin.on("message", () => plugin.terminate()); + const started = Date.now(); + await assert.rejects( + bridge.executePluginTask(new PluginTask("test", {}), "tab"), + /connection closed before the task finished/ + ); + // well within the 1s task timeout of these tests + assert.ok(Date.now() - started < 500); +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 913b28b26a..0c1136deea 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -15,6 +15,14 @@ import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatc const KEEP_ALIVE_TIME = 30000; // 30 seconds const INITIALIZATION_TIMEOUT_MS = 10_000; +/** + * The interval, in ms, between the heartbeat requests sent to each plugin. + * + * A browser runs the timers of a hidden page as rarely as once a minute, which would leave the + * plugin's own heartbeat older than the stale threshold, but it handles socket messages at once. + */ +export const HEARTBEAT_REQUEST_INTERVAL_MS = 10_000; + /** * Maximum plugin heartbeat age before a connection is stale. * @@ -187,8 +195,8 @@ export class PluginBridge implements TaskDispatchHost { } }); - ws.on("close", () => { - this.logger.info("WebSocket connection closed"); + ws.on("close", (code: number, reason: Buffer) => { + this.logger.info("WebSocket connection closed (code %d%s)", code, reason.length ? `: ${reason}` : ""); clearTimeout(initializationTimeout); this.removeConnection(ws); }); @@ -218,12 +226,17 @@ export class PluginBridge implements TaskDispatchHost { const pingInterval = setInterval(() => { ws.ping(); }, KEEP_ALIVE_TIME); + // the plugin answers with a heartbeat, which a frozen page cannot send + const heartbeatRequestInterval = setInterval(() => { + ws.send(JSON.stringify({ type: "heartbeat-request" })); + }, HEARTBEAT_REQUEST_INTERVAL_MS); // register the client connection with both indexes const connection: PenpotConnection = { socket: ws, userToken, pingInterval, + heartbeatRequestInterval, session, ready: false, lastHeartbeat: Date.now(), @@ -260,10 +273,11 @@ export class PluginBridge implements TaskDispatchHost { /** * Removes a client connection and releases all resources associated with it. * - * Clears the per-connection keep-alive interval and removes the connection from the - * socket-keyed index and the user's session collection. The token-keyed index entry - * is removed only when the user's last local connection closes. Safe to call with a - * socket that is not (or no longer) registered. + * Clears the per-connection keep-alive and heartbeat-request intervals and removes the + * connection from the socket-keyed index and the user's session collection. The + * token-keyed index entry is removed only when the user's last local connection closes. + * Tasks sent over the connection and still awaiting a response fail at once. Safe to call + * with a socket that is not (or no longer) registered. * * @param ws - The WebSocket whose connection state should be removed */ @@ -274,9 +288,17 @@ export class PluginBridge implements TaskDispatchHost { } connection.ready = false; clearInterval(connection.pingInterval); + clearInterval(connection.heartbeatRequestInterval); this.connectionsBySocket.delete(ws); const connections = this.getUserConnections(connection.userToken)!; connections.remove(connection); + // a task sent over the closed socket can no longer be answered, since responses only count + // from the connection the task went to + for (const [taskId, taskConnection] of [...this.taskConnections]) { + if (taskConnection === connection) { + this.rejectPendingTask(taskId, new Error("The plugin connection closed before the task finished.")); + } + } if (connection.userToken !== null && connections.size === 0) { this.userPenpotConnectionsByToken.delete(connection.userToken); } diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 3b7d86bd01..64b7eae00a 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -177,6 +177,24 @@ importers: specifier: ^3.9.10 version: 3.9.10 + packages/browser-extension: + devDependencies: + '@penpot/mcp-common': + specifier: workspace:../common + version: link:../common + '@types/chrome': + specifier: ^0.3.6 + version: 0.3.6 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/common: devDependencies: typescript: @@ -783,6 +801,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chrome@0.3.6': + resolution: {integrity: sha512-NXCrCBefCHCKMVbkaxfBQqyP766Wg85S2FQhixCCxq7AtjJwoEaja2y5NSqCzpihIi9mPqhaF5RuybTnrdYD2Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -792,6 +813,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1918,6 +1942,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 26.6.5 + '@types/chrome@0.3.6': + dependencies: + '@types/har-format': 1.2.16 + '@types/connect@3.4.38': dependencies: '@types/node': 26.6.5 @@ -1935,6 +1963,8 @@ snapshots: '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 + '@types/har-format@1.2.16': {} + '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 3da100dca6..5aa8300f88 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -19,6 +19,7 @@ packages: - "packages/common" - "packages/server" - "packages/plugin" + - "packages/browser-extension" overrides: '@hono/node-server@<2.0.5': ^2.0.5 diff --git a/mcp/scripts/build b/mcp/scripts/build index 3e5337fda2..671091ac9f 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -21,7 +21,7 @@ set +e set -e popd -pnpm -r --filter "!mcp-plugin" install; +pnpm -r --filter "!mcp-plugin" --filter "!mcp-browser-extension" install; pnpm -r --filter "mcp-server" run build; rsync -avr packages/server/dist/ ./dist/; From 41654aa1b7ef334ea450edc944969fde9b67afe7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 3 Nov 2026 13:49:36 +0100 Subject: [PATCH 210/325] :sparkles: Restrict management RPC methods to their declared shared keys (#12736) * :sparkles: Restrict management RPC methods to their shared keys Management methods now declare the shared-key ids allowed to call them with ::rpc/shared-keys. A wrapper in wrap-management checks the key id from the request before opening a transaction, so a valid key outside the declared set answers 403 shared-key-not-allowed. Methods without the metadata keep accepting any configured key. Scope upload-tempfile to exporter and media-processor, file analysis to nexus, durable jobs to exporter, and the Nitrate methods to admin-console. The test helper injects a declared key for the allowed path, and a coverage test fails when a management method is missing or declares the wrong set. Closes #12730 AI-assisted-by: deepseek-v4.1-flash * :recycle: Harden shared-key authorization scope and tests Rename the per-method metadata to ::rpc/allowed-shared-keys so it reads as the allowed key ids, not the secrets. Fix the management test helper to resolve the key from the supplied request and to keep an explicit nil, which prevents a rejection test from silently using an allowed key. Strengthen coverage: assert every management namespace is scanned, check all four job methods reject a key they do not declare, verify a rejected complete-job leaves the row and its events untouched, and exercise both keys allowed on upload-tempfile. Log denied keys with the method name and key id, without the secret, and update the audit-log memory for push-audit-events. Closes #12730 AI-assisted-by: deepseek-v4.1-flash --- .serena/memories/audit-log.md | 4 +- .../auth-permissions-product-domains.md | 1 + .serena/memories/backend/subtleties.md | 2 +- backend/src/app/rpc.clj | 27 +++++- backend/src/app/rpc/management/exporter.clj | 7 +- backend/src/app/rpc/management/files.clj | 7 +- backend/src/app/rpc/management/jobs.clj | 27 ++++-- backend/src/app/rpc/management/nitrate.clj | 36 ++++++++ backend/test/backend_tests/helpers.clj | 29 ++++++- .../backend_tests/rpc_file_security_test.clj | 8 ++ .../rpc_management_jobs_test.clj | 27 ++++++ .../rpc_management_nitrate_test.clj | 7 ++ .../rpc_management_shared_keys_test.clj | 49 +++++++++++ .../backend_tests/rpc_management_test.clj | 23 +++++ backend/test/backend_tests/rpc_test.clj | 87 +++++++++++++++++++ 15 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 backend/test/backend_tests/rpc_management_shared_keys_test.clj diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index a9766ec80b..03cfad7915 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -182,7 +182,7 @@ Who uses it: Failure policy: every send is best-effort. A failure never rolls back the business action; it is only recorded for debugging. -Backend side (`backend/src/app/rpc/management/nitrate.clj` `::push-audit-events`): `::audit/skip`, `::rpc/auth false` (trust comes from the shared key, not from a user session). It builds context with `audit/prepare-context-from-request` + `request-id`, takes the given `profile-id` as-is (no coercion here, must already be a uuid), sets `tracked-at = request-at`, defaults `type` to `"action"`, and calls `audit/submit` (which defaults `source` to `"backend"`). So these rows land as **`source = backend`**: they are audited like backend events because a trusted service set the author. +Backend side (`backend/src/app/rpc/management/nitrate.clj` `::push-audit-events`): `::audit/skip`, `::rpc/auth false` (trust comes from the shared key, not from a user session; only the `admin-console` key id is allowed). It builds context with `audit/prepare-context-from-request` + `request-id`, takes the given `profile-id` as-is (no coercion here, must already be a uuid), sets `tracked-at = request-at`, defaults `type` to `"action"`, and calls `audit/submit` (which defaults `source` to `"backend"`). So these rows land as **`source = backend`**: they are audited like backend events because a trusted service set the author. ### 7b. Browser channel (best-effort, like frontend events) @@ -301,7 +301,7 @@ So the full journey is: Penpot buffer → Nexus `audit_log` (via either archive Penpot → admin-console archiving lives in its own job (`:audit-log-consumer-admin-console`, consumer `2`, flag `:admin-console`, params closed empty): it drains its own pending queue and ingests allowlisted events through `nitrate/call :ingest-audit-log`, enriched as described above. What also exists (do not confuse with archiving): -- **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user. +- **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`; only the `admin-console` shared-key id can call it). Section 7a is the main user. - **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b). - **Penpot frontend Nitrate bits**: membership/subscription events (`frontend/src/app/main/data/nitrate.cljs`, `nitrate-audit/*`) through the same public endpoint. diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index d9ee64c171..c0667250f1 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -14,6 +14,7 @@ - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. - When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile. - Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous. +- Management RPC (`/api/management/methods/:name`) is shared-key only. The middleware authenticates ` `; the RPC wrapper `wrap-shared-key-authorization` authorizes the key id against the method's `::rpc/allowed-shared-keys` set. Absent metadata accepts any configured key; all current methods declare one (`exporter`/`media-processor` for `upload-tempfile`, `nexus` for file analysis, `exporter` for jobs, `admin-console` for Nitrate). A valid key outside the set answers 403 `:shared-key-not-allowed`; denied calls do not reach the audit wrapper. - Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous. - On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves. - HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 922c20bcb7..8bfef97b6e 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -56,7 +56,7 @@ - Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. A body the parser cannot read is a client error, classified at the parse site and never by exception class (`clojure.data.json` signals its syntax errors with a bare `java.lang.Exception`): 400 `:validation`/`:malformed-json`, one `warn` line (`malformed request body`) carrying path, peer address and content-type instead of a stack trace, and `penpot_http_malformed_requests_total`. `RequestTooBigException` keeps its 413. - Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. - Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`. Only `kid=1`/`ver=1` tokens are decoded as session tokens; anything else is left unauthenticated (legacy v1 tokens were removed). -- Shared-key auth requires `x-shared-key` as ` ` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. +- Shared-key auth requires `x-shared-key` as ` ` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. Management methods declare the key ids they accept with `::rpc/allowed-shared-keys`; a valid key outside that set answers 403 `:shared-key-not-allowed`, and a method without the metadata accepts any configured key. - Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. Sessions use only the v2 UUID model (`http_session_v2`); legacy string ids were removed. - Session cookies are renewed when `modified-at` is older than the 6h renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. Session lifetime config and GC: `mem:backend/session-expiration`. diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 8d922822c7..f9c9d7eabf 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -366,6 +366,28 @@ (wrap-params-validation cfg $ mdata) (wrap-authentication cfg $ mdata))) +(defn- wrap-shared-key-authorization + "Authorizes a shared-key caller against the method's `::allowed-shared-keys`. + + A management method declares the shared-key ids allowed to call it with + `::allowed-shared-keys`. When the metadata is absent any configured shared key is + accepted, preserving the previous behavior. When it is present and empty no + key is accepted." + [_ f mdata] + (if (contains? mdata ::allowed-shared-keys) + (let [allowed-keys (::allowed-shared-keys mdata)] + (fn [cfg params] + (if (contains? allowed-keys (::auth-key-id params)) + (f cfg params) + (do + (l/wrn :hint "shared key is not allowed for this method" + :handler-name (::handler-name params) + :key-id (::auth-key-id params)) + (ex/raise :type :authorization + :code :shared-key-not-allowed + :hint "shared key is not allowed for this method"))))) + f)) + (defn- wrap-management [cfg f mdata] (as-> f $ @@ -376,7 +398,10 @@ (wrap-audit cfg $ mdata) (wrap-nitrate-sso cfg $ mdata) (wrap-params-validation cfg $ mdata) - (wrap-authentication cfg $ mdata))) + (wrap-authentication cfg $ mdata) + ;; Outermost on purpose: a denied key never opens a transaction, + ;; validates params or runs the method body. + (wrap-shared-key-authorization cfg $ mdata))) (defn- process-method [cfg wrap-fn [f mdata]] diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 332fe23984..1e37e5943f 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -28,11 +28,16 @@ schema:upload-tempfile-result [:map {:title "upload-templfile-result"}]) +(def ^:private allowed-shared-key-ids + "The exporter uploads its artifacts; the media worker uploads its results." + #{:exporter :media-processor}) + (sv/defmethod ::upload-tempfile {::doc/added "2.12" ::sm/params schema:upload-tempfile-params ::rpc/multipart-only-params [:content] - ::sm/result schema:upload-tempfile-result} + ::sm/result schema:upload-tempfile-result + ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [::rpc/profile-id content]}] (media.v/validate-media-type! content cm/tempfile-types) (let [storage (sto/resolve cfg) diff --git a/backend/src/app/rpc/management/files.clj b/backend/src/app/rpc/management/files.clj index d947ec7549..377247b56c 100644 --- a/backend/src/app/rpc/management/files.clj +++ b/backend/src/app/rpc/management/files.clj @@ -28,6 +28,10 @@ (def projection-version 1) +(def ^:private allowed-shared-key-ids + "Only the nexus file-security pipeline reads file projections." + #{:nexus}) + ;; Bounds for the data that crosses to nexus on every sweep. The ;; projection stays small by construction; anything past the caps sets ;; `:truncated?` instead of growing the payload. @@ -220,7 +224,8 @@ {::doc/added "2.20" ::sm/params schema:get-file-for-analysis-params ::sm/result schema:get-file-for-analysis-result - ::rpc/auth false} + ::rpc/auth false + ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [file-id]}] ;; `realize?` loads the whole object tree eagerly: the projection ;; walks every shape, so lazy pointers (no `load-fn` bound here) diff --git a/backend/src/app/rpc/management/jobs.clj b/backend/src/app/rpc/management/jobs.clj index f5fc250c96..3fd79ff3d0 100644 --- a/backend/src/app/rpc/management/jobs.clj +++ b/backend/src/app/rpc/management/jobs.clj @@ -10,11 +10,12 @@ the ledger. The methods are family-agnostic (they operate on any row of the `job` table) and use the plain JSON schemas of `app.jobs`. - Auth: every method declares ::rpc/auth false because these endpoints - do not require a profile. Actual authentication is enforced by the - management route resolver (resolve-management-methods in app.rpc), - which mandates a valid shared-key before dispatching to these methods. - If the route resolver changes, these methods must be updated accordingly." + Auth: these endpoints take no profile, so every method declares + ::rpc/auth false. The route applies mw/shared-key-auth + (app.http.middleware) to authenticate the ` ` header, and + wrap-management applies wrap-shared-key-authorization (app.rpc) to + check the key id against ::rpc/allowed-shared-keys. If the route + middleware or wrap-management changes, revisit these methods." (:require [app.common.exceptions :as ex] [app.common.schema :as sm] @@ -24,6 +25,10 @@ [app.rpc.doc :as doc] [app.util.services :as sv])) +(def ^:private allowed-shared-key-ids + "Only the exporter worker claims and settles durable jobs today." + #{:exporter}) + ;; ---- RPC METHOD: CLAIM-JOB (def ^:private schema:claim-job-params @@ -42,7 +47,8 @@ {::doc/added "2.20" ::sm/params schema:claim-job-params ::sm/result schema:claim-job-result - ::rpc/auth false} ;; shared-key enforced by route resolver + ::rpc/auth false + ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [job-id scheduled-at]}] (let [row (jobs/get-job cfg job-id)] ;; Race condition: if the job is claimed between get-job and claim! by @@ -74,7 +80,8 @@ {::doc/added "2.20" ::sm/params schema:report-job-progress-params ::sm/result schema:report-job-progress-result - ::rpc/auth false} ;; shared-key enforced by route resolver + ::rpc/auth false + ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [job-id progress]}] ;; A lost race (row already terminal) reports :skip so the worker ;; stops retrying a report that can never land, mirroring claim-job. @@ -130,7 +137,8 @@ {::doc/added "2.20" ::sm/params schema:complete-job-params ::sm/result schema:complete-job-result - ::rpc/auth false} ;; shared-key enforced by route resolver + ::rpc/auth false + ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [job-id result resource-id]}] (if (pos? (jobs/complete cfg :job-id job-id @@ -156,7 +164,8 @@ {::doc/added "2.20" ::sm/params schema:fail-job-params ::sm/result schema:fail-job-result - ::rpc/auth false} ;; shared-key enforced by route resolver + ::rpc/auth false + ::rpc/allowed-shared-keys allowed-shared-key-ids} [cfg {:keys [job-id error]}] (if (pos? (jobs/fail cfg job-id error)) {:action :run} diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 20cd899ae7..13f8e01817 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -46,6 +46,10 @@ [cuerdas.core :as str])) +(def ^:private allowed-shared-key-ids + "The Nitrate admin-console is the only caller of this API." + #{:admin-console}) + (defn- profile-to-map [profile] {:id (:id profile) :name (:fullname profile) @@ -58,6 +62,7 @@ (sv/defmethod ::authenticate "Authenticate the current user" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map] ::sm/result schema:profile ::nitrate/sso false} @@ -105,6 +110,7 @@ (sv/defmethod ::get-penpot-version "Get the current Penpot version" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map] ::sm/result schema:get-penpot-version-result ::rpc/auth false} @@ -124,6 +130,7 @@ (sv/defmethod ::get-teams "List teams for which current user is owner" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map] ::sm/result schema:get-teams-result ::nitrate/sso false} @@ -135,6 +142,7 @@ (sv/defmethod ::get-member-teams "List all active teams where the current user is a member" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map] ::sm/result schema:get-member-teams-result ::nitrate/sso false} @@ -151,6 +159,7 @@ (sv/defmethod ::update-profile-theme "Update the current user's theme" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:update-profile-theme ::db/transaction true ::nitrate/sso false} @@ -171,6 +180,7 @@ (sv/defmethod ::update-profile-props "Merge writable properties into the current user's profile" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:update-profile-props ::db/transaction true ::nitrate/sso false} @@ -193,6 +203,7 @@ Accepts an optional previous-id to mark the old logo for garbage collection when replacing an existing one." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:upload-organization-logo ::rpc/multipart-only-params [:content] ::sm/result schema:upload-organization-logo-result @@ -225,6 +236,7 @@ (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:notify-team-change ::rpc/auth false} [cfg team] @@ -245,6 +257,7 @@ (sv/defmethod ::notify-user-added-to-organization "Notify to Penpot that an user has joined an organization from nitrate" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:notify-user-added-to-organization ::rpc/auth false} [cfg {:keys [profile-id organization-id]}] @@ -276,6 +289,7 @@ (sv/defmethod ::get-managed-profiles "List profiles that belong to teams for which current user is owner" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map] ::sm/result schema:managed-profile-result ::nitrate/sso false} @@ -316,6 +330,7 @@ (sv/defmethod ::get-teams-summary "Get summary information for a list of teams" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:get-teams-summary-params ::sm/result schema:get-teams-summary-result ::nitrate/sso false} @@ -437,6 +452,7 @@ RETURNING id, deleted_at;") "For a deleted organization, preserve organization teams and only prefix or delete imported Personal Projects before notifying connected users." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:notify-organization-deletion ::rpc/auth false} [cfg {:keys [organization-id]}] @@ -457,6 +473,7 @@ RETURNING id, deleted_at;") "For a given user, find all owned organizations and apply the deleted-organization transfer rules to their imported Personal Projects teams." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:notify-user-organizations-deletion ::nitrate/sso false} [cfg {:keys [profile-id]}] @@ -483,6 +500,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-profile-by-email "Get profile by email" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map [:email ::sm/email]] ::sm/result schema:profile ::nitrate/sso false} @@ -507,6 +525,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-profile-by-id "Get profile by email" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map [:id ::sm/uuid]] ::sm/result schema:profile ::nitrate/sso false} @@ -542,6 +561,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-organization-member-team-counts "Get the number of non-default teams each profile belongs to within a set of teams." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:get-organization-member-team-counts-params ::sm/result schema:get-organization-member-team-counts-result ::rpc/auth false} @@ -594,6 +614,7 @@ RETURNING id, deleted_at;") "Get the non-default teams a profile belongs to within a set of teams, with the profile's role in each one." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:get-organization-member-teams-params ::sm/result schema:get-organization-member-teams-result ::nitrate/sso false} @@ -631,6 +652,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::invite-to-organization "Invite to organization" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map [:email ::sm/email] [:organization cto/schema:organization-with-avatar]] @@ -663,6 +685,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-organization-invitations "Get valid invitations for an organization, returning at most one invitation per email." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:get-organization-invitations-params ::sm/result schema:get-organization-invitations-result ::nitrate/sso false} @@ -691,6 +714,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::delete-organization-invitations "Delete all invitations for one email in an organization scope (organization + organization teams)." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:delete-organization-invitations-params ::nitrate/sso false} [cfg {:keys [organization-id email]}] @@ -718,6 +742,7 @@ RETURNING id, deleted_at;") Called from Nitrate when an organization is about to be deleted, so users that click their invitation token hit the existing invalid-token landing page." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:delete-all-organization-invitations-params ::rpc/auth false} [cfg {:keys [organization-id]}] @@ -756,6 +781,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::remove-from-organization "Remove an user from an organization" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] @@ -801,6 +827,7 @@ RETURNING id, deleted_at;") "Get a summary of the teams that would be deleted, transferred, or exited if the user were removed from the organization" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] @@ -836,6 +863,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::send-renewal-email "Send an Enterprise subscription renewal notice email to a user." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:send-renewal-email-params ::rpc/auth false} [cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}] @@ -912,6 +940,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::exists-organization-team-invitations-for-non-members "Return if there are any team invitations for emails that are not organization members." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:organization-team-invitations-for-non-members-params ::sm/result schema:exists-organization-team-invitations-for-non-members-result ::nitrate/sso false} @@ -922,6 +951,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::delete-organization-team-invitations-for-non-members "Delete team invitations for emails that are not organization members." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:organization-team-invitations-for-non-members-params ::db/transaction true ::nitrate/sso false} @@ -954,6 +984,7 @@ RETURNING id, deleted_at;") events)" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::audit/skip true ::sm/params schema:push-audit-events-params ::rpc/auth false} @@ -1061,6 +1092,7 @@ RETURNING id, deleted_at;") "Get detailed information for all non-deleted teams in an organization, including owner info and project/file/member counts." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:get-teams-detail-params ::sm/result schema:get-teams-detail-result ::nitrate/sso false} @@ -1091,6 +1123,7 @@ RETURNING id, deleted_at;") (customer-configured), so it is checked against the SSRF blocklist before any outbound request is attempted." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params cto/schema:nitrate-sso ::sm/result schema:check-organization-sso-result ::rpc/auth false} @@ -1104,6 +1137,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-organization-sso-change "Nitrate notifies that an organization sso values have changed" {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map [:organization-id ::sm/uuid] [:updated-props ::sm/boolean] @@ -1163,6 +1197,7 @@ RETURNING id, deleted_at;") email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled by default so it is only available on test environments." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params schema:bulk-create-profiles-params ::sm/result schema:bulk-create-profiles-result ::rpc/auth false} @@ -1197,6 +1232,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-air-gapped "Returns whether this Penpot instance runs in air-gapped mode." {::doc/added "2.18" + ::rpc/allowed-shared-keys allowed-shared-key-ids ::sm/params [:map] ::sm/result schema:get-air-gapped-result ::rpc/auth false} diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 60dd537836..24c433b012 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -547,13 +547,38 @@ (method-fn params)))) (defn management-command! + "Invokes a management RPC method. + + Resolves the shared-key id from an explicit `::rpc/auth-key-id`, from + `:app.http/auth-key-id` on the data or on the supplied request, and falls + back to the first id the method declares. The fallback lets the existing + tests exercise the allowed path without repeating the key; a test that + checks a rejection declares the key explicitly, including `nil`." [{:keys [::type] :as data}] - (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])] + (let [[mdata method-fn] (get-in *system* [:app.rpc/management-methods type])] (when-not method-fn (ex/raise :type :assertion :code :rpc-method-not-found :hint (str/ffmt "management rpc method '%' not found" (name type)))) - (let [params (prepare-rpc-params data)] + (let [request (-> data meta :app.http/request) + key-id (cond + (contains? data ::rpc/auth-key-id) + (::rpc/auth-key-id data) + + (contains? data :app.http/auth-key-id) + (:app.http/auth-key-id data) + + (and (map? request) + (contains? request :app.http/auth-key-id)) + (:app.http/auth-key-id request) + + :else + (first (::rpc/allowed-shared-keys mdata))) + data (if (or (contains? data ::rpc/auth-key-id) + (nil? key-id)) + data + (assoc data ::rpc/auth-key-id key-id)) + params (prepare-rpc-params data)] (try-on! (method-fn params))))) (def multipart-headers diff --git a/backend/test/backend_tests/rpc_file_security_test.clj b/backend/test/backend_tests/rpc_file_security_test.clj index 15ac56c53d..1ecaf3de7f 100644 --- a/backend/test/backend_tests/rpc_file_security_test.clj +++ b/backend/test/backend_tests/rpc_file_security_test.clj @@ -218,6 +218,14 @@ (t/is (some? (:error out))) (t/is (= :not-found (th/ex-type (:error out)))))) +(t/deftest get-file-for-analysis-rejects-a-key-it-does-not-declare + (let [out (th/management-command! {::th/type :get-file-for-analysis + ::rpc/auth-key-id :exporter + :file-id (uuid/random)})] + (t/is (some? (:error out))) + (t/is (= :authorization (th/ex-type (:error out)))) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out)))))) + ;; --- Audit prop coverage (through the commands, into real events) ;; ;; NOTE: the test system neutralizes `audit/submit`, so no audit row diff --git a/backend/test/backend_tests/rpc_management_jobs_test.clj b/backend/test/backend_tests/rpc_management_jobs_test.clj index 9ff0882e68..c228289528 100644 --- a/backend/test/backend_tests/rpc_management_jobs_test.clj +++ b/backend/test/backend_tests/rpc_management_jobs_test.clj @@ -13,6 +13,7 @@ [app.db :as db] [app.jobs :as jobs] [app.metrics :as-alias mtx] + [app.rpc :as-alias rpc] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -76,6 +77,12 @@ id]) (mapv #(jobs/decode-progress (:payload %))))) +(defn- count-job-events + [id] + (-> (th/db-exec! ["SELECT count(*) AS count FROM job_event WHERE job_id = ?" id]) + first + :count)) + (defn- mgmt [type params] (th/management-command! (assoc params ::th/type type))) @@ -134,6 +141,26 @@ (let [out (mgmt :claim-job {:job-id (uuid/next)})] (t/is (= :validation (th/ex-type (:error out)))))) +(t/deftest jobs-methods-reject-a-key-they-do-not-declare + (doseq [type [:claim-job :report-job-progress :complete-job :fail-job]] + (let [out (mgmt type {::rpc/auth-key-id :nexus})] + (t/is (= :authorization (th/ex-type (:error out))) (name type)) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out))) (name type))))) + +(t/deftest complete-job-rejects-a-key-it-does-not-declare-without-side-effects + (let [job-id (mk-job {}) + before (get-row job-id) + events-before (count-job-events job-id) + out (mgmt :complete-job {::rpc/auth-key-id :nexus + :job-id job-id + :result {:x 1}}) + after (get-row job-id)] + (t/is (= :authorization (th/ex-type (:error out)))) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out)))) + (t/is (= (:status before) (:status after))) + (t/is (nil? (:completed-at after))) + (t/is (= events-before (count-job-events job-id))))) + (t/deftest report-job-progress-appends-a-progress-event (let [job-id (mk-job {}) _ (jobs/claim (make-cfg) job-id (:scheduled-at (th/db-get :job {:id job-id} :id :scheduled-at))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 2d411bf816..cdef65b340 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -52,6 +52,13 @@ (t/is (= :authentication (th/ex-type (:error out)))) (t/is (= :authentication-required (th/ex-code (:error out)))))) +(t/deftest authenticate-rejects-a-key-it-does-not-declare + (let [out (th/management-command! {::th/type :authenticate + ::rpc/auth-key-id :exporter})] + (t/is (not (th/success? out))) + (t/is (= :authorization (th/ex-type (:error out)))) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out)))))) + (t/deftest create-and-update-organization-invitations-audit-props (let [owner-id-ref (atom nil)] (with-mocks [email-mock {:target 'app.email/send :return nil} diff --git a/backend/test/backend_tests/rpc_management_shared_keys_test.clj b/backend/test/backend_tests/rpc_management_shared_keys_test.clj new file mode 100644 index 0000000000..ea78696293 --- /dev/null +++ b/backend/test/backend_tests/rpc_management_shared_keys_test.clj @@ -0,0 +1,49 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-management-shared-keys-test + "Covers the shared-key scope declared by every management RPC method." + (:require + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-admin-console]) + th/init-system)) + +(def ^:private expected-shared-keys + {"app.rpc.management.exporter" #{:exporter :media-processor} + "app.rpc.management.files" #{:nexus} + "app.rpc.management.jobs" #{:exporter} + "app.rpc.management.nitrate" #{:admin-console}}) + +(t/deftest every-management-method-declares-its-shared-keys + (let [methods (get th/*system* :app.rpc/management-methods) + seen (into #{} (map (comp :ns first val)) methods)] + (t/is (seq methods)) + (t/is (= (set (keys expected-shared-keys)) seen) + "every management namespace must be scanned") + (doseq [[method-name [mdata _]] methods] + (let [ns-name (:ns mdata) + expected (get expected-shared-keys ns-name)] + (t/is (some? expected) + (str "unexpected management namespace for " method-name ": " ns-name)) + (t/is (= expected (::rpc/allowed-shared-keys mdata)) + (str "wrong ::rpc/allowed-shared-keys for " method-name " in " ns-name)))))) + +(t/deftest management-command-uses-the-key-from-the-request-metadata + (let [out (th/management-command! + (with-meta {::th/type :upload-tempfile} + {:app.http/request {:app.http/auth-key-id :nexus}}))] + (t/is (= :authorization (th/ex-type (:error out)))) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out)))))) + +(t/deftest management-command-keeps-an-explicit-nil-key + (let [out (th/management-command! {::th/type :upload-tempfile + ::rpc/auth-key-id nil})] + (t/is (= :authorization (th/ex-type (:error out)))) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out)))))) diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index a55dcf95a4..eec3aaabb9 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -82,6 +82,29 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :media-type-not-allowed (th/ex-code (:error out)))))) +(t/deftest upload-tempfile-rejects-a-key-it-does-not-declare + (let [out (th/management-command! {::th/type :upload-tempfile + ::rpc/auth-key-id :nexus})] + (t/is (some? (:error out))) + (t/is (= :authorization (th/ex-type (:error out)))) + (t/is (= :shared-key-not-allowed (th/ex-code (:error out)))))) + +(t/deftest upload-tempfile-accepts-both-declared-keys + (let [profile (th/create-profile* 1 {:is-active true})] + (doseq [key-id [:exporter :media-processor]] + (let [path (fs/create-tempfile :dir "/tmp/penpot" + :prefix (str "test-upload-" (name key-id) "-")) + _ (io/write* path "content") + out (th/multipart-management-command! + {::th/type :upload-tempfile + ::rpc/auth-key-id key-id + ::rpc/profile-id (:id profile) + :content {:filename "export.png" + :path path + :mtype "image/png" + :size 7}})] + (t/is (nil? (:error out)) (name key-id)))))) + (t/deftest duplicate-file (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) diff --git a/backend/test/backend_tests/rpc_test.clj b/backend/test/backend_tests/rpc_test.clj index a6e18d24f3..0be65dd983 100644 --- a/backend/test/backend_tests/rpc_test.clj +++ b/backend/test/backend_tests/rpc_test.clj @@ -7,6 +7,7 @@ (ns backend-tests.rpc-test (:require [app.common.exceptions :as ex] + [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -923,6 +924,92 @@ (call-body (method {}) {:extras {::actoken/profile-id pid ::actoken/type "personal"}})))))) +;; --- SHARED KEY AUTHORIZATION + +(defn- call-shared-key-wrapper + [mdata params] + (let [wrapped (#'rpc/wrap-shared-key-authorization + nil (fn [_ params] params) mdata)] + (wrapped nil params))) + +(t/deftest shared-key-authorization-allows-declared-keys + (let [mdata {::rpc/allowed-shared-keys #{:exporter :media-processor}}] + (t/is (= :exporter + (::rpc/auth-key-id + (call-shared-key-wrapper mdata {::rpc/auth-key-id :exporter})))) + (t/is (= :media-processor + (::rpc/auth-key-id + (call-shared-key-wrapper mdata {::rpc/auth-key-id :media-processor})))))) + +(t/deftest shared-key-authorization-rejects-other-key + (let [ex (try (call-shared-key-wrapper {::rpc/allowed-shared-keys #{:exporter}} + {::rpc/auth-key-id :nexus}) + (catch Exception e e))] + (t/is (th/ex-of-type? ex :authorization)) + (t/is (th/ex-of-code? ex :shared-key-not-allowed)))) + +(t/deftest shared-key-authorization-rejects-missing-key + (let [ex (try (call-shared-key-wrapper {::rpc/allowed-shared-keys #{:exporter}} {}) + (catch Exception e e))] + (t/is (th/ex-of-type? ex :authorization)) + (t/is (th/ex-of-code? ex :shared-key-not-allowed)))) + +(t/deftest shared-key-authorization-without-metadata-allows-any-key + (t/is (= :nexus + (::rpc/auth-key-id + (call-shared-key-wrapper {} {::rpc/auth-key-id :nexus})))) + (t/is (nil? (::rpc/auth-key-id (call-shared-key-wrapper {} {}))))) + +(t/deftest shared-key-authorization-empty-set-denies-all + (let [ex (try (call-shared-key-wrapper {::rpc/allowed-shared-keys #{}} + {::rpc/auth-key-id :exporter}) + (catch Exception e e))] + (t/is (th/ex-of-type? ex :authorization)) + (t/is (th/ex-of-code? ex :shared-key-not-allowed)))) + +(t/deftest shared-key-authorization-logs-the-denied-key + (let [records (atom []) + watch (fn [_ _ _ record] (swap! records conj record))] + (add-watch l/log-record ::shared-key-denied watch) + (try + (try (call-shared-key-wrapper {::rpc/allowed-shared-keys #{:exporter}} + {::rpc/auth-key-id :nexus + ::rpc/handler-name "test-method"}) + (catch Exception _)) + (let [deadline (+ (System/currentTimeMillis) 2000) + logged? (fn [] + (some #(and (= :warn (::l/level %)) + (= "shared key is not allowed for this method" + (get (::l/props %) :hint)) + (= :nexus (get (::l/props %) :key-id)) + (= "test-method" (get (::l/props %) :handler-name))) + @records))] + (loop [] + (when-not (or (logged?) + (>= (System/currentTimeMillis) deadline)) + (Thread/sleep 10) + (recur))) + (t/is (some? (logged?)))) + (finally + (remove-watch l/log-record ::shared-key-denied))))) + +(t/deftest shared-key-authorization-is-applied-to-rpc-requests + (let [mdata {::rpc/allowed-shared-keys #{:exporter}} + wrapped (partial (#'rpc/wrap-shared-key-authorization + nil (fn [_ params] params) mdata) + nil) + handler (make-handler {:shared-key-test [mdata wrapped]})] + (t/is (= :exporter + (::rpc/auth-key-id + (call-body handler + {:path-params {:method-name "shared-key-test"} + :extras {::http/auth-key-id :exporter}})))) + (t/is (th/ex-of-code? + (call-ex handler + {:path-params {:method-name "shared-key-test"} + :extras {::http/auth-key-id :nexus}}) + :shared-key-not-allowed)))) + ;; --- PARAMS VALIDATION: SERVER AUTH CONTEXT VS CLIENT PARAMS (t/deftest client-qualified-keys-do-not-override-server-auth-context From b2be633b9123cd40f804d27dc4df9c1bc0798072 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:02:56 +0100 Subject: [PATCH 211/325] :arrow_up: Update pnpm to v12.16.1 (#12778) - pnpm 12.16.1 (mcp/packages/browser-extension/package.json) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- mcp/packages/browser-extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/packages/browser-extension/package.json b/mcp/packages/browser-extension/package.json index 0f1c0fe8ae..6553da34c0 100644 --- a/mcp/packages/browser-extension/package.json +++ b/mcp/packages/browser-extension/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "description": "Browser extension that keeps Penpot tabs connected to MCP from being discarded", "type": "module", - "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", + "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1", "scripts": { "build": "tsc && node ./scripts/build.mjs", "test": "tsx --test \"src/**/*.test.ts\"", From 2bf9f4c78b7da5f3d8beeb8ea50af0bcae07c000 Mon Sep 17 00:00:00 2001 From: "penpot-renovate[bot]" <338182062+penpot-renovate[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:03:28 +0100 Subject: [PATCH 212/325] :arrow_up: Update DHI base images (#12767) - dhi.io/debian-base trixie-debian13-dev@af61a32 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13-dev@af61a32 (docker/images/Dockerfile.backend) - dhi.io/debian-base trixie-debian13@834d20c (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@af61a32 (docker/imagemagick/Dockerfile) - dhi.io/debian-base trixie-debian13-dev@af61a32 (docker/devenv/Dockerfile) - dhi.io/nginx 1.31.8-debian13-dev@f69a0ea (docker/images/Dockerfile.frontend) - dhi.io/node 24.22.2-debian13-dev@df819cf (docker/images/Dockerfile.media-processor) - dhi.io/node 24.22.2-debian13-dev@df819cf (docker/images/Dockerfile.mcp) - dhi.io/node 24.22.2-debian13-dev@df819cf (docker/images/Dockerfile.exporter) Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com> --- docker/devenv/Dockerfile | 2 +- docker/imagemagick/Dockerfile | 4 ++-- docker/images/Dockerfile.backend | 4 ++-- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.mcp | 2 +- docker/images/Dockerfile.media-processor | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index a41de1feb1..74b89fa83a 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS base +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:af61a3296b0c92f16969f11ce8c2a93722e84c9e5c1bb42ed6afec796be71b04 AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index 405d5e2f8c..7567117f9d 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:af61a3296b0c92f16969f11ce8c2a93722e84c9e5c1bb42ed6afec796be71b04 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -74,7 +74,7 @@ RUN set -ex; \ rm -rf /var/lib/apt/lists/* -FROM dhi.io/debian-base:trixie-debian13@sha256:fefc620cea08f18e60c8f1afbf670200463384726b6f31f8d59f8a4707976f81 AS image +FROM dhi.io/debian-base:trixie-debian13@sha256:834d20c209fe4aac50dc95877d68d421f97ea38a905586bfdaf938bebb3924c0 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index b62bc0e8a2..3edb969257 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS build +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:af61a3296b0c92f16969f11ce8c2a93722e84c9e5c1bb42ed6afec796be71b04 AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -53,7 +53,7 @@ RUN set -eux; \ --output /opt/jre; -FROM dhi.io/debian-base:trixie-debian13-dev@sha256:9e479c64f8d27f8fa368ef62804c2ddb9b3e43a68c13a200b2f668b4771b049e AS image +FROM dhi.io/debian-base:trixie-debian13-dev@sha256:af61a3296b0c92f16969f11ce8c2a93722e84c9e5c1bb42ed6afec796be71b04 AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index f10fe4ab3e..bb6fa216da 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.2-debian13-dev@sha256:3083ddf2287edc010579950602f4e07659b08b23efa74ee1de7582931dbe31cf +FROM dhi.io/node:24.22.2-debian13-dev@sha256:df819cfcb0de8a9e461bb8437d96d6fcfac341e565129e50597338756a88db5b LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 91c2192132..aba52d5bcc 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:f8c15eca396af66e240b9258323fb97e29abd534b76e7cf34acfdd3bc74dd282 +FROM dhi.io/nginx:1.31.8-debian13-dev@sha256:f69a0ea1762c51080fe96ce5f12103fb2977231308ae1784acd915bf63a0a1a2 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index e54c34ac2d..4054a613b0 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.22.2-debian13-dev@sha256:3083ddf2287edc010579950602f4e07659b08b23efa74ee1de7582931dbe31cf AS build +FROM dhi.io/node:24.22.2-debian13-dev@sha256:df819cfcb0de8a9e461bb8437d96d6fcfac341e565129e50597338756a88db5b AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index ff12e10bd2..218fd84ecd 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM dhi.io/node:24.22.2-debian13-dev@sha256:3083ddf2287edc010579950602f4e07659b08b23efa74ee1de7582931dbe31cf +FROM dhi.io/node:24.22.2-debian13-dev@sha256:df819cfcb0de8a9e461bb8437d96d6fcfac341e565129e50597338756a88db5b LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ From d1c8b0f95995a07167fae642cf3ecf79eb18d601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Tue, 3 Nov 2026 16:06:16 +0100 Subject: [PATCH 213/325] :bug: Report tokens named below another token with tokenscript (#12529) A token cannot contain tokens: next to a token `a`, the tokens `a.b` and `a.b.c` are now invalid with tokenscript, whatever the order of their sets. As with StyleDictionary, their pills and the token form show "Group name of a.b conflicts with a token of the same name in another active set." and `{a.b}` is a missing reference. The interpreter reports them as :processor/token-name-collision, which the adapter maps to :error.token/name-collision. frontend/deps.edn updates penpot/tokenscript-interpreter to v0.2.0, which adds the check. Since v0.1.0 its errors carry codes and data only, which the adapter already relies on. Signed-off-by: Florian Schroedl --- frontend/deps.edn | 4 ++-- frontend/src/app/main/data/tokenscript.cljs | 3 +++ .../tokens/tokenscript_test.cljs | 19 ++++++++++++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/frontend/deps.edn b/frontend/deps.edn index 7e19542857..ab7197c661 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -31,8 +31,8 @@ :git/url "https://github.com/funcool/rumext.git"} penpot/tokenscript-interpreter - {:git/tag "v0.1.0" - :git/sha "224547d" + {:git/tag "v0.2.0" + :git/sha "20db9b1" :git/url "https://github.com/penpot/tokenscript-interpreter.git"} instaparse/instaparse {:mvn/version "1.5.0"} diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index f002d49bed..9a5a3a6b74 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -212,6 +212,9 @@ :interpreter/unknown-function (wte/error-with-value :error.token/unknown-function (:function-name data)) + :processor/token-name-collision + (wte/error-with-value :error.token/name-collision (:token-name data)) + (:functions/argument-out-of-range :functions/invalid-base) (wte/error-with-value :error.token/function-argument-out-of-range (:function-name data)) diff --git a/frontend/test/frontend_tests/tokens/tokenscript_test.cljs b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs index f6c1b8e01d..22ee6bf712 100644 --- a/frontend/test/frontend_tests/tokens/tokenscript_test.cljs +++ b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs @@ -85,16 +85,25 @@ (t/is (= {:error/code :error.style-dictionary/invalid-token-value :error/value "{zero} + 1"} (error "ref")) "a token failing through its reference is an invalid value"))) -;; A token named "a" and a token named "a.b" can coexist: `{a}` resolves -;; to the token, `{a.b}` to the nested one (#9584). +;; A token cannot contain tokens: next to a token "a", the tokens "a.b" +;; and "a.b.c" get a name collision error and `{a.b}` is missing (#9584). (t/deftest resolve-tokens-name-collision-test (let [resolved (ts/resolve-tokens (tokens-map {:name "a" :value "8px" :type :border-radius} {:name "a.b" :value "12px" :type :border-radius} - {:name "c" :value "{a} + {a.b}" :type :border-radius}))] + {:name "a.b.c" :value "1px" :type :border-radius} + {:name "c" :value "{a} + {a.b}" :type :border-radius})) + error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))] (t/is (= 8 (get-in resolved ["a" :resolved-value]))) - (t/is (= 12 (get-in resolved ["a.b" :resolved-value]))) - (t/is (= 20 (get-in resolved ["c" :resolved-value]))))) + (t/is (nil? (get-in resolved ["a" :errors]))) + (t/is (nil? (get-in resolved ["a.b" :resolved-value]))) + (t/is (= {:error/code :error.token/name-collision :error/value "a.b"} (error "a.b"))) + (t/is (= {:error/code :error.token/name-collision :error/value "a.b.c"} (error "a.b.c"))) + (t/is (= {:error/code :error.style-dictionary/missing-reference :error/value ["a.b"]} (error "c")))) + (t/testing "a new token below an existing one collides" + (let [tokens (tokens-map {:name "rad1" :value "8px" :type :border-radius}) + resolved (ts/resolve-token tokens {:name "rad1.bad" :value "10" :type :border-radius})] + (t/is (= :error.token/name-collision (get-in resolved [:errors 0 :error/code])))))) (t/deftest resolve-tokens-composites-test (t/testing "typography and shadow tokens resolve field by field" From b19ad6558167451b1bbc96c34874754eecc10f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero-Cantero?= <807608+alvorithm@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:06:44 +0100 Subject: [PATCH 214/325] :wrench: Store devenv workspace clones in the XDG data directory (#11986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manage.sh has kept the ws1+ workspace clones in ~/.penpot/penpot_workspaces since parallel instances landed (#9906). That directory sits outside the XDG base directory layout. The default is now $XDG_DATA_HOME/penpot-devenv/workspaces, with ~/.local/share standing in when the variable is unset or empty. It is the data directory and not the cache, because a clone can hold commits that exist nowhere else. A directory that already exists at the former default keeps being used, so existing clones are neither orphaned nor seeded again. An exported PENPOT_WORKSPACES_DIR still overrides both. AI-assisted-by: claude-opus-5-5 Signed-off-by: Álvaro Tejero Cantero --- .serena/memories/devenv/core.md | 2 +- docs/technical-guide/developer/devenv.md | 5 +++-- manage.sh | 13 ++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 415a69342e..3507a5d6e0 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -5,7 +5,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par ## Compose project layout - `penpotdev-infra`: shared `postgres`, `rustfs`, `valkey`, `mailer`, `ldap`. File: `docker-compose.infra.yml`. -- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer. +- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `${XDG_DATA_HOME:-~/.local/share}/penpot-devenv/workspaces/`, or the former `~/.penpot/penpot_workspaces/` when it already exists), maintained by the developer. - Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount. - All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands. diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 6ec4c8fc25..697d119b1c 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -66,8 +66,9 @@ The devenv runs as separate compose projects: * `penpotdev-wsN` project per runtime instance. - `ws0` (a.k.a. `main`) is the current state of your repo; - `ws1` and up are clones that you maintain explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/` - (default `~/.penpot/penpot_workspaces/`). You can explicitly sync them - with the `--sync` flag (automatic on first start). + (default `${XDG_DATA_HOME:-~/.local/share}/penpot-devenv/workspaces/`, or + `~/.penpot/penpot_workspaces/` when that directory already exists). You + can explicitly sync them with the `--sync` flag (automatic on first start). Each call to `run-devenv` brings up one instance. Workspaces are independent and can be started and stopped in any order: diff --git a/manage.sh b/manage.sh index 950c184001..2696b9eefe 100755 --- a/manage.sh +++ b/manage.sh @@ -40,7 +40,18 @@ export PENPOT_SOURCE_PATH="${PENPOT_SOURCE_PATH:-$PWD}" # Base directory under which non-main workspace clones live (one subdir per # wsN, N>=1). Documented in defaults.env; default lives here so $HOME expands. -export PENPOT_WORKSPACES_DIR="${PENPOT_WORKSPACES_DIR:-$HOME/.penpot/penpot_workspaces}" +# The default follows the XDG base directory spec, in the data directory +# because a clone can hold commits that exist nowhere else. A directory that +# already exists at the former default, ~/.penpot/penpot_workspaces, keeps +# being used, so existing clones are neither orphaned nor seeded again. +if [[ -z "${PENPOT_WORKSPACES_DIR:-}" ]]; then + if [[ -d "$HOME/.penpot/penpot_workspaces" ]]; then + PENPOT_WORKSPACES_DIR="$HOME/.penpot/penpot_workspaces" + else + PENPOT_WORKSPACES_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/penpot-devenv/workspaces" + fi +fi +export PENPOT_WORKSPACES_DIR # Port allocation for parallel instances. Each wsN reserves a stride-wide port # block starting at N*stride; ws0 sits at offset 0, so a per-service base port From 029d9c0af7f4ada4737a945406179b4c9a51f031 Mon Sep 17 00:00:00 2001 From: GennoBou <121762542+GennoBou@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:11:22 +0900 Subject: [PATCH 215/325] :bug: Fix Japanese autodetection fallback on "ja" locale (#12283) Most browsers return "ja" rather than "ja-JP" in navigator.language. Because parse-locale expands "ja-JP" to ["ja_jp" "ja"] but keeps "ja" as ["ja"], using "ja_jp" causes autodetection to fall back to English. Normalize the Japanese locale identifier to "ja" and map jpn_JP.po to translation.ja.js, matching other single-region locales like Ukrainian. AI-assisted-by: gemini-3.8-flash --- frontend/scripts/_helpers.js | 2 +- frontend/src/app/util/i18n.cljs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index 9a6bf14ec8..2b59eb9038 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -328,7 +328,7 @@ export async function compileTranslations() { "nl", // this happens when file does not matches correct // iso code for the language. - ["ja_jp", "jpn_JP"], + ["ja", "jpn_JP"], ["uk", "ukr_UA"], "ha", ]; diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index ce7712ae4e..d36b2872c7 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -55,7 +55,7 @@ {:label "आधुनिक मानक हिन्दी (community)" :value "hi"} {:label "عربي/عربى (community)" :value "ar"} {:label "فارسی (community)" :value "fa"} - {:label "日本語 (Community)" :value "ja_jp"} + {:label "日本語 (Community)" :value "ja"} {:label "简体中文 (community)" :value "zh_cn"} {:label "繁體中文 (community)" :value "zh_hant"}]) From 88cd0933bd8146494751d7cd544bc33639b43eb8 Mon Sep 17 00:00:00 2001 From: Nimesh <167794819+GitNimay@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:51:28 +0530 Subject: [PATCH 216/325] :bug: Raise and make configurable the MCP request body size limit (#12450) The MCP endpoint parsed request bodies with express.json() and its default 100kb limit, so large tool calls such as execute_code failed with HTTP 413 (PayloadTooLargeError). Read the limit from the new PENPOT_MCP_MAX_BODY_SIZE env var, defaulting to 10mb, and document it. Closes #12080 Signed-off-by: Nimesh Kulkarni <167794819+GitNimay@users.noreply.github.com> Co-authored-by: Andrey Antukh --- mcp/README.md | 1 + mcp/packages/server/src/PenpotMcpServer.http.test.ts | 7 +++++++ mcp/packages/server/src/PenpotMcpServer.ts | 11 ++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index 4c206d9e35..632abf4254 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -298,6 +298,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | +| `PENPOT_MCP_MAX_BODY_SIZE` | Maximum size of a JSON request body accepted by the MCP endpoint, in any format supported by Express (e.g. `10mb`) | `10mb` | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | | `PENPOT_MCP_AUTH_URI` | Public base URL of the MCP authorization service, e.g. `https://penpot.example/api/mcp/oauth`. Set to enable OAuth; requires `--multi-user`. Use HTTPS (HTTP is allowed on loopback for development), without credentials, query parameters, or a fragment. | (unset; OAuth disabled) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | diff --git a/mcp/packages/server/src/PenpotMcpServer.http.test.ts b/mcp/packages/server/src/PenpotMcpServer.http.test.ts index 693e3197ab..ad2e75d446 100644 --- a/mcp/packages/server/src/PenpotMcpServer.http.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.http.test.ts @@ -126,6 +126,13 @@ test("passes an explicit Penpot session ID from the tool call to plugin dispatch assert.match(JSON.stringify(body), /chosen-tab/); }); +test("accepts tool calls with request bodies larger than 100 KiB", async (t) => { + t.mock.method(server.pluginBridge, "executePluginTask", async () => ({ data: "ok" })); + const code = `return "${"x".repeat(200_000)}".length;`; + const response = await modernRequest("tools/call", { name: "execute_code", arguments: { code } }); + assert.equal(response.status, 200); +}); + test("supports older Streamable HTTP clients without allocating a session", async () => { const client = new Client({ name: "legacy-test", version: "1" }); const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`)); diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 445fb4ee4e..56f7ac5b2c 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -147,6 +147,14 @@ export class PenpotMcpServer { */ private readonly tenant: string; + /** + * Maximum size of a JSON request body, read from the `PENPOT_MCP_MAX_BODY_SIZE` environment variable. + * + * Accepts any value supported by Express' `json` body parser (e.g. `"10mb"`). Defaults to `"10mb"`, + * since tool calls such as `execute_code` can carry large payloads. + */ + private readonly maxBodySize: string; + constructor(private isMultiUser: boolean = false) { this.authentication = new McpAuthentication(process.env.PENPOT_MCP_AUTH_URI, isMultiUser); // read port configuration from environment variables @@ -156,6 +164,7 @@ export class PenpotMcpServer { this.replHost = process.env.PENPOT_MCP_REPL_HOST ?? "localhost"; this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10); this.tenant = process.env.PENPOT_TENANT ?? "default"; + this.maxBodySize = process.env.PENPOT_MCP_MAX_BODY_SIZE ?? "10mb"; const toolTimeoutSecs = parseInt(process.env.PENPOT_MCP_TOOL_TIMEOUT_S ?? "120", 10); this.configLoader = new ConfigurationLoader(process.cwd()); @@ -323,7 +332,7 @@ export class PenpotMcpServer { async start(): Promise { const { default: express } = await import("express"); this.app = express(); - this.app.use(express.json()); + this.app.use(express.json({ limit: this.maxBodySize })); this.setupHttpEndpoints(); From 51cbae6ea5b265bbfde7ad249e8a09dc85bdd359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 3 Nov 2026 17:41:36 +0100 Subject: [PATCH 217/325] :zap: Improve memory consumption on shape decoding (#12790) * :zap: Avoid decoding the opened page twice for its frame index The frame index was built over a with-meta copy, so pages got decoded again. Read it from the page objects themselves and fix the with-meta identity check. * :zap: Intern keywords when decoding transit on the frontend Each shape is decoded on its own, so every shape had its own key keywords. Cache them in the read handler so all decoded data shares one instance. * :zap: Use an array map as the wasm shape proxy delegate A hash map with ~35 keys costs ~3 KB of nodes per shape on large pages. An array map holds the same entries in one array, at a small lookup cost. --- common/src/app/common/transit.cljc | 15 +++++++ common/src/app/common/types/objects_map.cljc | 2 +- common/src/app/common/types/shape_tree.cljc | 10 ++++- frontend/src/app/render_wasm/shape.cljs | 45 +++++++++++++++----- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/common/src/app/common/transit.cljc b/common/src/app/common/transit.cljc index dc3e37b380..41b503316f 100644 --- a/common/src/app/common/transit.cljc +++ b/common/src/app/common/transit.cljc @@ -99,6 +99,17 @@ ;; --- HANDLERS +#?(:cljs + (defn- keyword-reader + "Return a reader that gives the same keyword instance for each name." + [] + (let [cache (js/Map.)] + (fn [value] + (or (.get cache value) + (let [kw (keyword value)] + (.set cache value kw) + kw)))))) + (add-handlers! #?@(:clj [{:id "file" @@ -119,6 +130,10 @@ {:id "u" :rfn parse-uuid}) + #?(:cljs + {:id ":" + :rfn (keyword-reader)}) + {:id "ordered-map" :class #?(:clj LinkedMap :cljs lkm/LinkedMap) :wfn vec diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index 77289a2b1e..1b4e17ce4c 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -206,7 +206,7 @@ c/IWithMeta (-with-meta [this new-meta] - (if (identical? new-meta meta) + (if (identical? new-meta metadata) this (ObjectsMap. new-meta cache diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 0080aae00d..05cb0fc40f 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -368,11 +368,17 @@ (defn start-page-index [objects] - (with-meta objects {::index-frames (get-frames (with-meta objects nil))})) + (with-meta objects {::index-frames (into [] (get-frames objects {:ignore-index? true}))})) (defn update-page-index + "Lazy, so pages that are not open are not decoded. The index reads + from the returned objects, so decoded shapes are shared with them." [objects] - (with-meta objects {::index-frames (get-frames (with-meta objects nil))})) + (let [self (volatile! nil) + objects (with-meta objects + {::index-frames (lazy-seq (get-frames @self {:ignore-index? true}))})] + (vreset! self objects) + objects)) (defn update-object-indices [file page-id] diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index d94bd8d070..1d2980d6c9 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -79,6 +79,16 @@ (-count [_] (+ 1 (count delegate))) + c/IKVReduce + (-kv-reduce [_ f init] + (let [acc (f init :id id)] + (if (reduced? acc) + @acc + (let [acc (f acc :type type)] + (if (reduced? acc) + @acc + (reduce-kv f acc delegate)))))) + c/ILookup (-lookup [coll k] (-lookup coll k nil)) @@ -354,6 +364,18 @@ (let [id (:id self)] (.set changes id (conj* (.get changes id) k))))) +(defn- assoc-delegate + "Keeps an array map delegate as an array map when adding a new key." + [delegate k v] + (if (and (instance? PersistentArrayMap delegate) + (not (contains? delegate k))) + (PersistentArrayMap. (meta delegate) + (inc (count delegate)) + (doto (aclone (.-arr ^PersistentArrayMap delegate)) + (.push k v)) + nil) + (assoc delegate k v))) + (defn- impl-assoc [self k v] (track-change! self k) @@ -369,7 +391,7 @@ (.-delegate ^ShapeProxy self)) (let [delegate (.-delegate ^ShapeProxy self) - delegate' (assoc delegate k v)] + delegate' (assoc-delegate delegate k v)] (if (identical? delegate' delegate) self (ShapeProxy. (.-id ^ShapeProxy self) @@ -418,18 +440,19 @@ (next es)) (throw (js/Error. "conj on a map takes map entries or seqables of map entries")))))))) -(def ^:private xf:without-id-and-type - (remove (fn [kvpair] - (let [k (key kvpair)] - (or (= k :id) - (= k :type)))))) - (defn create-shape - "Instantiate a shape from a map" + "Instantiate a shape from a map. The delegate is an array map because + it takes far less memory than a hash map for shape-sized maps." [attrs] - (ShapeProxy. (:id attrs) - (:type attrs) - (into {} xf:without-id-and-type attrs))) + (let [arr (reduce-kv (fn [arr k v] + (when-not (or (= k :id) (= k :type)) + (.push arr k v)) + arr) + #js [] + attrs)] + (ShapeProxy. (:id attrs) + (:type attrs) + (PersistentArrayMap. nil (/ (alength arr) 2) arr nil)))) (t/add-handlers! ;; We only add a write handler, read handler uses the dynamic dispatch From a089f7a5a1cb00f1c48b69e5d0bf69a58a0303fe Mon Sep 17 00:00:00 2001 From: Luke Lau Date: Wed, 4 Nov 2026 17:44:30 +0800 Subject: [PATCH 218/325] :bug: Fix library telemetry counters (#12787) At some point the code around this changed, I believe this used to reduce over file ids. However today it reduces over entire library maps, so it passes a map instead of an id to `dm/get-in files`. This then hashes the library which forces it to decode all of its shapes, and then it fails to lookup the data. So the component/colors/typography counts were always returning 0. Using reduce-kv fixes this, and we can just get the library data directly with `(:data library)`. On the backstage file in penpot-files this brings the time to the telemetry snapshot down from 3.8s to 1.5s AI-assisted-by: claude-5.5-sonnet Co-authored-by: Andrey Antukh --- frontend/src/app/main/data/event.cljs | 17 ++++---- .../test/frontend_tests/data/event_test.cljs | 40 ++++++++++++++++++- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index c381583e12..64070e1e85 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -8,7 +8,6 @@ (:require ["@penpot/ua-parser" :as ua] [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.json :as json] [app.common.logging :as l] [app.common.math :as math] @@ -316,14 +315,14 @@ 0))) (add-libraries-counters [state files] - (reduce (fn [state library-id] - (let [data (dm/get-in files [library-id :data])] - (-> state - (update :total-components + (count (:components data))) - (update :total-colors + (count (:colors data))) - (update :total-typographies + (count (:typographies data)))))) - state - (refs/select-libraries files file-id)))] + (reduce-kv (fn [state _ library] + (let [data (:data library)] + (-> state + (update :total-components + (count (:components data))) + (update :total-colors + (count (:colors data))) + (update :total-typographies + (count (:typographies data)))))) + state + (refs/select-libraries files file-id)))] (ptk/reify ::snapshot-performance-info ptk/UpdateEvent diff --git a/frontend/test/frontend_tests/data/event_test.cljs b/frontend/test/frontend_tests/data/event_test.cljs index 9add050dd4..04a184e94f 100644 --- a/frontend/test/frontend_tests/data/event_test.cljs +++ b/frontend/test/frontend_tests/data/event_test.cljs @@ -6,12 +6,14 @@ (ns frontend-tests.data.event-test (:require + [app.common.uuid :as uuid] [app.main.data.event :as ev] [app.main.repo :as rp] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as async] - [frontend-tests.helpers.mock :as mock])) + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) (t/deftest events-enabled?-truth-table (t/testing "enabled when audit-log is present" @@ -41,3 +43,39 @@ {rp/cmd! (mock/stub (fn [_cmd] (rx/throw (ex-info "boom" {}))))} (await (async/->promise (ev/fetch-environment-data)))))] (t/is (= {:flags #{:telemetry}} data)))) + +(t/deftest snapshot-performance-info-counts-libraries + (let [file-id (uuid/next) + lib-id (uuid/next) + other-id (uuid/next) + unlinked (uuid/next) + sized (fn [n] (zipmap (repeatedly n uuid/next) (repeat {}))) + data (fn [components colors typographies] + {:components (sized components) + :colors (sized colors) + :typographies (sized typographies)}) + state {:files + {file-id {:id file-id + :data (-> (data 1 1 1) + (assoc :pages-index (zipmap (repeatedly 2 uuid/next) + [{:objects (sized 4)} + {:objects (sized 2)}])))} + lib-id {:id lib-id + :library-of file-id + :data (data 2 3 1)} + other-id {:id other-id + :library-of file-id + :data (data 1 2 2)} + unlinked {:id unlinked + :library-of (uuid/next) + :data (data 3 3 3)}}} + state' (ptk/update (#'ev/snapshot-performance-info {:file-id file-id}) state) + counters (get-in state' [:performance-info :counters])] + + (t/testing "counts shapes of the current file" + (t/is (= 6 (:total-shapes counters)))) + + (t/testing "sums the data of the file and its libraries, not other files" + (t/is (= 4 (:total-components counters))) + (t/is (= 6 (:total-colors counters))) + (t/is (= 4 (:total-typographies counters)))))) From e6153b61a5ee5795c48098f924ed40c82c9f4d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Wed, 4 Nov 2026 10:56:50 +0100 Subject: [PATCH 219/325] :recycle: Extract the token pill state and tooltip from its component (#12691) The token pill worked out inside the component whether its token is applied to the selection, whether it can apply to it at all and why the token is invalid, so no test could reach that logic. Move it into `pill-state`, and split the tooltip into `tooltip-info`, which decides what it says, and `render-tooltip`, which renders it. Both are pure and get unit tests for the applied, partial, disabled and error states and each tooltip case. The two chained `on-click` handlers become one. Nothing else changes in behavior. Signed-off-by: Florian Schroedl --- .../tokens/management/token_pill.cljs | 258 +++++++++--------- frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/token_pill_test.cljs | 220 +++++++++++++++ 3 files changed, 347 insertions(+), 133 deletions(-) create mode 100644 frontend/test/frontend_tests/tokens/token_pill_test.cljs diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index d98c481028..a54a932e42 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -109,61 +109,59 @@ (str/join ", " (map attribute-dictionary values)) "."))) grouped-values))) -(defn- generate-tooltip - "Generates a tooltip for a given token" - [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set error-message] - (let [{:keys [name type resolved-value value]} token - resolved-value-theme (:resolved-value theme-token) - resolved-value (or resolved-value-theme resolved-value) - {:keys [title] :as token-props} (dwta/get-token-properties theme-token) +(defn- tooltip-info + "For an invalid token the tooltip is only its error message. Otherwise + it shows the token's name and values and, for viewers, the attributes + of `shape` the token is applied to." + [token resolved-token shape state is-viewer] + (let [{:keys [name type value]} token + {:keys [is-half-applied has-missing-reference has-invalid-value + error-message]} state + resolved-value (or (:resolved-value resolved-token) (:resolved-value token)) + {:keys [title] :as token-props} (dwta/get-token-properties resolved-token) applied-tokens (:applied-tokens shape) - app-token-vals (set (vals applied-tokens)) app-token-keys (keys applied-tokens) - is-applied? (contains? app-token-vals name) - - - applied-to (if half-applied - (partially-applied-attr app-token-keys is-applied? token-props) - (tr "labels.all")) - grouped-values (group-by dimensions-dictionary app-token-keys) - - name-line - (mf/html [:* (dm/str (tr "workspace.tokens.token-name") ": ") - [:span {:class (stl/css :token-pill-tooltip-name)} name]]) - - rest-of-title (dm/str (tr "workspace.tokens.original-value" (dwtf/format-token-value value)) "\n" - (tr "workspace.tokens.resolved-value" (dwtf/format-token-value resolved-value)) - (when (= (:type token) :number) - (dm/str "\n" (tr "workspace.tokens.more-options"))))] + is-applied (contains? (set (vals applied-tokens)) name)] (cond - ;; If there are errors, show the appropriate message - ref-not-in-active-set - (tr "workspace.tokens.ref-not-valid" name) + has-missing-reference + {:message (tr "workspace.tokens.ref-not-valid" name)} error-message - error-message + {:message error-message} - no-valid-value - (tr "workspace.tokens.value-not-valid") + has-invalid-value + {:message (tr "workspace.tokens.value-not-valid")} - ;; If the token is applied and the user is a is-viewer, show the details - (and is-applied? is-viewer) - (mf/html - [:* - name-line - [:div rest-of-title] - [:div (tr "workspace.tokens.applied-to")] - [:div (if (= :dimensions type) - (translate-and-format grouped-values) - (str "- " title ": " applied-to))]]) - - ;; Otherwise only show the base title :else + {:name name + :value (dwtf/format-token-value value) + :resolved-value (dwtf/format-token-value resolved-value) + :has-more-options (= type :number) + :applied-to + (when (and is-applied is-viewer) + (if (= :dimensions type) + (translate-and-format (group-by dimensions-dictionary app-token-keys)) + (str "- " title ": " + (if is-half-applied + (partially-applied-attr app-token-keys is-applied token-props) + (tr "labels.all")))))}))) + +(defn- render-tooltip + [{:keys [message name value resolved-value has-more-options applied-to]}] + (or message (mf/html [:* - name-line - [:div rest-of-title]])))) + (dm/str (tr "workspace.tokens.token-name") ": ") + [:span {:class (stl/css :token-pill-tooltip-name)} name] + [:div (dm/str (tr "workspace.tokens.original-value" value) "\n" + (tr "workspace.tokens.resolved-value" resolved-value) + (when has-more-options + (dm/str "\n" (tr "workspace.tokens.more-options"))))] + (when applied-to + [:* + [:div (tr "workspace.tokens.applied-to")] + [:div applied-to]])]))) ;; FIXME: the token thould already have precalculated references, so ;; we don't need to perform this regex operation on each rerender @@ -195,12 +193,69 @@ (def token-types-with-status-icon #{:color :border-radius :rotation :sizing :dimensions :opacity :spacing :stroke-width}) +(defn- pill-state + "Works out whether the token is applied to the selection, partially + applied, disabled or invalid. Without a selection it counts as fully + applied." + [token active-theme-tokens selected-shapes is-selected-inside-layout] + (let [{:keys [name value type]} token + errors (:errors (get active-theme-tokens name)) + has-selected (seq selected-shapes) + tokenscript (contains? cf/flags :tokenscript) + + attributes + (as-> (get dwta/token-properties type) $ + (d/nilv (:all-attributes $) (:attributes $))) + + full-applied + (or (not has-selected) + (applied-all-attributes? token selected-shapes attributes)) + + applied + (boolean (and has-selected + (cfo/shapes-token-applied? token selected-shapes attributes))) + + half-applied (and applied (not full-applied)) + + missing-reference + (boolean + (if tokenscript + (wte/has-error-code? :error.style-dictionary/missing-reference errors) + (and (cfo/is-reference? token) + (not (contains-reference-value? value active-theme-tokens))))) + + name-collision + (= :error.token/name-collision (:error/code (first errors))) + + invalid-value (boolean (seq errors))] + + {:is-applied applied + :is-half-applied half-applied + :is-disabled + (boolean + (and has-selected + (not applied) + (not (attributes-match-selection? + selected-shapes attributes + {:selected-inside-layout? is-selected-inside-layout})))) + :has-missing-reference missing-reference + :has-invalid-value invalid-value + ;; Tokenscript errors carry their own message, as in the edit form. + :error-message + (when (and invalid-value (or tokenscript name-collision)) + (wte/resolve-error-message (first errors))) + :has-errors (or missing-reference invalid-value) + :status-icon-id + (cond + half-applied "token-status-partial" + full-applied "token-status-full" + :else "token-status-non-applied")})) + (mf/defc token-pill* {::mf/wrap [mf/memo]} [{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens can-edit]}] - (let [{:keys [name value type]} token - resolved-token (get active-theme-tokens (:name token)) - errors (:errors resolved-token) + (let [{:keys [name type]} token + resolved-token (get active-theme-tokens name) pill-ref (mf/use-ref nil) pill-id (mf/use-id) @@ -208,59 +263,14 @@ tooltip-content* (mf/use-state "") tooltip-content (deref tooltip-content*) - has-selected? (pos? (count selected-shapes)) - is-reference? (cfo/is-reference? token) contains-path? (str/includes? name ".") + is-viewer? (not can-edit) - attributes (as-> (get dwta/token-properties type) $ - (d/nilv (:all-attributes $) (:attributes $))) - - full-applied? - (if has-selected? - (applied-all-attributes? token selected-shapes attributes) - true) - - applied? - (if has-selected? - (cfo/shapes-token-applied? token selected-shapes attributes) - false) - - half-applied? - (and applied? (not full-applied?)) - - disabled? (and - has-selected? - (not applied?) - (not half-applied?) - (not (attributes-match-selection? selected-shapes attributes {:selected-inside-layout? is-selected-inside-layout}))) - - - is-viewer? (not can-edit) - - tokenscript? (contains? cf/flags :tokenscript) - - ref-not-in-active-set - (if tokenscript? - (wte/has-error-code? :error.style-dictionary/missing-reference errors) - (and is-reference? - (not (contains-reference-value? value active-theme-tokens)))) - - name-collision (->> errors - (first) - (:error/code) - (= :error.token/name-collision)) - - no-valid-value (seq errors) - - ;; Tokenscript errors carry their own message, as in the edit form. - error-message - (when (and no-valid-value (or tokenscript? name-collision)) - (wte/resolve-error-message (first errors))) - - errors? - (or ref-not-in-active-set - no-valid-value - name-collision) + {:keys [is-applied is-disabled has-invalid-value error-message + has-errors status-icon-id] + :as state} + (mf/with-memo [token active-theme-tokens selected-shapes is-selected-inside-layout] + (pill-state token active-theme-tokens selected-shapes is-selected-inside-layout)) color (when (cfo/color-token? token) @@ -269,23 +279,6 @@ status-icon? (contains? token-types-with-status-icon type) - on-click - (mf/use-fn - (mf/deps errors? on-click token) - (fn [event] - (dom/stop-propagation event) - (when (and (not (seq errors)) on-click) - (on-click event token)))) - - token-status-id - (cond - half-applied? - "token-status-partial" - full-applied? - "token-status-full" - :else - "token-status-non-applied") - on-context-menu (mf/use-fn (mf/deps can-edit on-context-menu token) @@ -296,20 +289,19 @@ on-click (mf/use-fn - (mf/deps errors? on-click can-edit) + (mf/deps can-edit has-invalid-value on-click token) (fn [event] (dom/stop-propagation event) - (when (and can-edit (not (seq errors)) on-click) - (on-click event)))) + (when (and can-edit (not has-invalid-value) on-click) + (on-click event token)))) on-hover (mf/use-fn - (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set error-message) + (mf/deps token resolved-token selected-shapes state is-viewer?) (fn [_] - (let [theme-token (get active-theme-tokens name) - title (generate-tooltip is-viewer? (first selected-shapes) theme-token token - half-applied? no-valid-value ref-not-in-active-set error-message)] - (reset! tooltip-content* title)))) + (->> (tooltip-info token resolved-token (first selected-shapes) state is-viewer?) + (render-tooltip) + (reset! tooltip-content*)))) ;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped ;; in a function to satisfy the tooltip's `:content` schema; this @@ -326,20 +318,20 @@ [:button {:ref pill-ref :class (stl/css-case :token-pill true - :token-pill-no-icon (and (not status-icon?) (not errors?)) + :token-pill-no-icon (and (not status-icon?) (not has-errors)) :token-pill-default can-edit - :token-pill-disabled disabled? - :token-pill-applied (and can-edit applied?) - :token-pill-invalid (and can-edit errors?) - :token-pill-invalid-applied (and applied? errors? can-edit) + :token-pill-disabled is-disabled + :token-pill-applied (and can-edit is-applied) + :token-pill-invalid (and can-edit has-errors) + :token-pill-invalid-applied (and is-applied has-errors can-edit) :token-pill-viewer is-viewer? :token-pill-applied-viewer (and is-viewer? - applied?) + is-applied) :token-pill-invalid-viewer (and is-viewer? - errors?) + has-errors) :token-pill-invalid-applied-viewer (and is-viewer? - applied?)) + is-applied)) :id (str "token-pill-" (:id token)) :type "button" :on-focus on-hover @@ -349,7 +341,7 @@ :on-context-menu on-context-menu} (cond - errors? + has-errors [:> icon* {:icon-id i/broken-link :class (stl/css :token-pill-icon) @@ -362,7 +354,7 @@ status-icon? [:> token-status-icon* - {:icon-id token-status-id + {:icon-id status-icon-id :class (stl/css :token-pill-icon)}]) (if contains-path? diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5b21228c1d..63fee9982b 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -115,6 +115,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.token-path-test] + [frontend-tests.tokens.token-pill-test] [frontend-tests.tokens.tokenscript-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] @@ -287,6 +288,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.token-path-test + 'frontend-tests.tokens.token-pill-test 'frontend-tests.tokens.tokenscript-test 'frontend-tests.tokens.logic.tokens-source-test 'frontend-tests.tokens.logic.tokens-status-test diff --git a/frontend/test/frontend_tests/tokens/token_pill_test.cljs b/frontend/test/frontend_tests/tokens/token_pill_test.cljs new file mode 100644 index 0000000000..24476b91c0 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/token_pill_test.cljs @@ -0,0 +1,220 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.token-pill-test + (:require + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.config :as cf] + [app.main.ui.workspace.tokens.management.token-pill :as wtp] + [app.util.i18n :refer [tr]] + [cljs.test :as t :include-macros true])) + +(t/use-fixtures :each thi/test-fixture) + +(def ^:private pill-state @#'wtp/pill-state) +(def ^:private tooltip-info @#'wtp/tooltip-info) + +(def ^:private radius + (ctob/make-token :name "radius.md" :type :border-radius :value "8")) + +(def ^:private spacing + (ctob/make-token :name "spacing.md" :type :spacing :value "16")) + +(def ^:private width + (ctob/make-token :name "width.md" :type :dimensions :value "100")) + +(def ^:private active-theme-tokens + {"radius.md" (assoc radius :resolved-value 8) + "spacing.md" (assoc spacing :resolved-value 16) + "width.md" (assoc width :resolved-value 100)}) + +(defn- with-token + [shape token attributes] + (cto/apply-token-to-shape {:shape shape :token token :attributes attributes})) + +(defn- state + [token selected-shapes & {:keys [tokens is-selected-inside-layout] + :or {tokens active-theme-tokens}}] + (pill-state token tokens selected-shapes is-selected-inside-layout)) + +(defn- with-flags + [flags f] + (with-redefs [cf/flags flags] + (f))) + +(def ^:private no-tokenscript-flags (disj cf/flags :tokenscript)) +(def ^:private tokenscript-flags (conj cf/flags :tokenscript)) + +(t/deftest applied-status + (t/testing "without a selection the token counts as fully applied" + (t/is (= {:is-applied false + :is-half-applied false + :is-disabled false + :status-icon-id "token-status-full"} + (select-keys (state radius []) + [:is-applied :is-half-applied :is-disabled :status-icon-id])))) + + (t/testing "applied to every attribute of every selected shape" + (let [result (state radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys) + (with-token (ths/sample-shape :rect2) radius cto/border-radius-keys)])] + (t/is (true? (:is-applied result))) + (t/is (false? (:is-half-applied result))) + (t/is (= "token-status-full" (:status-icon-id result))))) + + (t/testing "applied to only some of the attributes" + (let [result (state radius [(with-token (ths/sample-shape :rect1) radius #{:r1})])] + (t/is (true? (:is-applied result))) + (t/is (true? (:is-half-applied result))) + (t/is (= "token-status-partial" (:status-icon-id result))))) + + (t/testing "applied to only some of the selected shapes" + (let [result (state radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys) + (ths/sample-shape :rect2)])] + (t/is (true? (:is-half-applied result))) + (t/is (= "token-status-partial" (:status-icon-id result))))) + + (t/testing "another token on the same attributes does not count" + (let [other (ctob/make-token :name "radius.lg" :type :border-radius :value "16") + result (state radius [(with-token (ths/sample-shape :rect1) other #{:r1})])] + (t/is (false? (:is-applied result))) + (t/is (= "token-status-non-applied" (:status-icon-id result))))) + + (t/testing "color tokens count all their attributes, not only the fill" + (let [color (ctob/make-token :name "brand" :type :color :value "#ff0000")] + (t/is (true? (:is-half-applied + (state color [(with-token (ths/sample-shape :rect1) color #{:fill})] + :tokens {"brand" color})))) + (t/is (false? (:is-half-applied + (state color [(with-token (ths/sample-shape :rect1) color #{:fill :stroke-color})] + :tokens {"brand" color}))))))) + +(t/deftest disabled-status + (t/testing "a token that applies to none of the selected shapes is disabled" + (t/is (true? (:is-disabled (state radius [(ths/sample-shape :circle1 :type :circle)])))) + (t/is (true? (:is-disabled (state spacing [(ths/sample-shape :rect1)]))))) + + (t/testing "one shape the token applies to is enough" + (t/is (false? (:is-disabled (state radius [(ths/sample-shape :circle1 :type :circle) + (ths/sample-shape :rect1)]))))) + + (t/testing "spacing applies to boards with a layout" + (t/is (false? (:is-disabled (state spacing [(ths/sample-shape :board1 :type :frame :layout :flex)]))))) + + (t/testing "spacing applies as margin to shapes inside a layout" + (t/is (false? (:is-disabled (state spacing [(ths/sample-shape :rect1)] + :is-selected-inside-layout true))))) + + (t/testing "an applied token is never disabled" + (t/is (false? (:is-disabled (state radius [(with-token (ths/sample-shape :circle1 :type :circle) + radius cto/border-radius-keys)])))))) + +(t/deftest error-status + (t/testing "a valid token has no errors" + (let [result (state radius [])] + (t/is (false? (:has-errors result))) + (t/is (nil? (:error-message result))))) + + (t/testing "a reference to a token outside the active sets" + (with-flags no-tokenscript-flags + #(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.missing}") + result (state token [])] + (t/is (true? (:has-missing-reference result))) + (t/is (false? (:has-invalid-value result))) + (t/is (true? (:has-errors result))) + (t/is (nil? (:error-message result))))) + + (with-flags no-tokenscript-flags + #(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.md}")] + (t/is (false? (:has-errors (state token [])))))) + + (with-flags tokenscript-flags + #(let [error {:error/code :error.style-dictionary/missing-reference + :message "Missing reference"} + token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.missing}") + result (state token [] :tokens {"radius.ref" (assoc token :errors [error])})] + (t/is (true? (:has-missing-reference result))) + (t/is (true? (:has-invalid-value result))) + (t/is (= "Missing reference" (:error-message result)))))) + + (t/testing "an invalid value only shows its own message with tokenscript" + (let [error {:error/code :error.style-dictionary/invalid-token-value + :message "Invalid value"} + tokens {"radius.md" (assoc radius :errors [error])}] + (with-flags no-tokenscript-flags + #(let [result (state radius [] :tokens tokens)] + (t/is (true? (:has-invalid-value result))) + (t/is (true? (:has-errors result))) + (t/is (nil? (:error-message result))))) + (with-flags tokenscript-flags + #(t/is (= "Invalid value" (:error-message (state radius [] :tokens tokens))))))) + + (t/testing "a name collision shows its message" + (with-flags no-tokenscript-flags + #(let [error {:error/code :error.token/name-collision + :error/value "radius.md" + :error/fn (fn [value] (str "Collides: " value))} + result (state radius [] :tokens {"radius.md" (assoc radius :errors [error])})] + (t/is (true? (:has-errors result))) + (t/is (= "Collides: radius.md" (:error-message result))))))) + +(defn- tooltip + [token shapes & {:keys [tokens is-viewer] :or {tokens active-theme-tokens}}] + (tooltip-info token (get tokens (:name token)) (first shapes) + (pill-state token tokens shapes false) + is-viewer)) + +(t/deftest tooltip-messages + (t/testing "errors replace the token details" + (with-flags no-tokenscript-flags + #(let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.missing}")] + (t/is (= {:message (tr "workspace.tokens.ref-not-valid" "radius.ref")} + (tooltip token []))))) + + (with-flags no-tokenscript-flags + #(let [error {:error/code :error.style-dictionary/invalid-token-value}] + (t/is (= {:message (tr "workspace.tokens.value-not-valid")} + (tooltip radius [] :tokens {"radius.md" (assoc radius :errors [error])})))))) + + (t/testing "a valid token shows its value and resolved value" + (t/is (= {:name "radius.md" + :value "8" + :resolved-value "8" + :has-more-options false + :applied-to nil} + (tooltip radius [])))) + + (t/testing "the resolved value comes from the active themes" + (let [token (ctob/make-token :name "radius.ref" :type :border-radius :value "{radius.md}")] + (t/is (= "8" (:resolved-value + (tooltip token [] :tokens (assoc active-theme-tokens + "radius.ref" (assoc token :resolved-value 8))))))))) + +(t/deftest tooltip-applied-to + (t/testing "editors don't see where the token is applied" + (t/is (nil? (:applied-to (tooltip radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys)]))))) + + (t/testing "viewers see the attributes of the first selected shape" + (t/is (= (str "- Border Radius: " (tr "labels.all")) + (:applied-to (tooltip radius [(with-token (ths/sample-shape :rect1) radius cto/border-radius-keys)] + :is-viewer true)))) + + (t/is (= "- Border Radius: Top Left" + (:applied-to (tooltip radius [(with-token (ths/sample-shape :rect1) radius #{:r1})] + :is-viewer true))))) + + (t/testing "dimension tokens list their attributes by category" + (t/is (= "- Sizing: Width." + (:applied-to (tooltip width [(with-token (ths/sample-shape :rect1) width #{:width})] + :is-viewer true))))) + + (t/testing "nothing is listed when the token isn't applied to the first shape" + (t/is (nil? (:applied-to (tooltip radius [(ths/sample-shape :rect1) + (with-token (ths/sample-shape :rect2) + radius cto/border-radius-keys)] + :is-viewer true)))))) From 9b672785aff350d1b1a6bffeac046b954285de36 Mon Sep 17 00:00:00 2001 From: Luke Lau Date: Wed, 4 Nov 2026 18:29:39 +0800 Subject: [PATCH 220/325] :bug: Fix gap handle drag leaving a stuck preview in wasm (#12782) AI-assisted-by: claude-sonnet-5-5 --- .../src/app/main/ui/flex_controls/gap.cljs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/main/ui/flex_controls/gap.cljs b/frontend/src/app/main/ui/flex_controls/gap.cljs index c297807e2d..f7d409a336 100644 --- a/frontend/src/app/main/ui/flex_controls/gap.cljs +++ b/frontend/src/app/main/ui/flex_controls/gap.cljs @@ -243,7 +243,8 @@ (+ (:x2 (:selrect shape)) (get-in shape [:layout-item-margin :m2]))) y (:y1 block) h (- (:y2 block) (:y1 block))] - {:x x + {:key (str frame-id "-" (:id shape)) + :x x :y y :height h :width (:column-gap gap) @@ -254,7 +255,7 @@ :gap-type (if is-col? :row-gap :column-gap)})) create-cgdd-block - (fn [block] + (fn [index block] (let [x (if flip-x (- (:x1 block) (:column-gap gap)) (:x2 block)) @@ -262,7 +263,8 @@ (+ y1 (:p3 padding)) (+ y1 (:p1 padding))) h (- height (+ (:p1 padding) (:p3 padding)))] - {:x x + {:key (str frame-id "-block-" index) + :x x :y y :width (:column-gap gap) :height h @@ -282,7 +284,8 @@ (:row-gap gap)) (+ (:y2 (:selrect shape)) (get-in shape [:layout-item-margin :m3]))) w (- (:x2 block) (:x1 block))] - {:x x + {:key (str frame-id "-" (:id shape)) + :x x :y y :width w :height (:row-gap gap) @@ -293,7 +296,7 @@ :gap-type (if is-col? :row-gap :column-gap)})) create-rgdd-block - (fn [block] + (fn [index block] (let [x (if flip-x (+ x1 (:p2 padding)) (+ x1 (:p4 padding))) @@ -301,7 +304,8 @@ (- (:y1 block) (:row-gap gap)) (:y2 block)) w (- width (+ (:p2 padding) (:p4 padding)))] - {:x x + {:key (str frame-id "-block-" index) + :x x :y y :width w :height (:row-gap gap) @@ -313,9 +317,9 @@ display-blocks (if is-col? (->> (drop-last wrap-blocks) - (map create-cgdd-block)) + (map-indexed create-cgdd-block)) (->> (drop-last wrap-blocks) - (map create-rgdd-block))) + (map-indexed create-rgdd-block))) display-children (if is-col? (->> children-to-display @@ -324,10 +328,10 @@ (map create-cgdd)))] [:g.gaps {:pointer-events "visible"} - (for [[index display-item] (d/enumerate (concat display-blocks display-children))] + (for [display-item (concat display-blocks display-children)] (let [gap-type (:gap-type display-item)] [:> gap-display* - {:key (str frame-id index) + {:key (:key display-item) :frame-id frame-id :zoom zoom :gap-type gap-type From 4ee2a812bef285550ec962ba08727084dd5ede69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 4 Nov 2026 11:30:53 +0100 Subject: [PATCH 221/325] :fire: Remove Beta badge from MCP server in Integrations (#12813) The Integrations settings page labelled the MCP server as Beta, while the MCP docs give no maturity status. Drop the badge, its style and the unused translation key so the UI no longer presents it as beta. Closes #12800 AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/ui/settings/integrations.cljs | 6 +----- frontend/src/app/main/ui/settings/integrations.scss | 8 -------- frontend/translations/ca.po | 4 ---- frontend/translations/en.po | 4 ---- frontend/translations/es.po | 4 ---- frontend/translations/fa.po | 4 ---- frontend/translations/fr.po | 4 ---- frontend/translations/fr_CA.po | 4 ---- frontend/translations/he.po | 4 ---- frontend/translations/it.po | 4 ---- frontend/translations/jpn_JP.po | 4 ---- frontend/translations/nl.po | 4 ---- frontend/translations/ru.po | 4 ---- frontend/translations/sv.po | 4 ---- frontend/translations/tr.po | 4 ---- frontend/translations/zh_CN.po | 4 ---- frontend/translations/zh_Hant.po | 4 ---- 17 files changed, 1 insertion(+), 73 deletions(-) diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index 334ca32806..159dc1414b 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -486,11 +486,7 @@ [:> heading* {:level 2 :typography t/title-medium :class (stl/css :color-primary :mcp-server-title)} - (tr "integrations.mcp-server.title")] - [:> text* {:as "span" - :typography t/body-small - :class (stl/css :beta)} - (tr "integrations.mcp-server.title.beta")]] + (tr "integrations.mcp-server.title")]] [:> text* {:as "div" :typography t/body-medium diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss index 174cf8a73d..c224fed5d8 100644 --- a/frontend/src/app/main/ui/settings/integrations.scss +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -27,14 +27,6 @@ inline-size: fit-content; } -.beta { - color: var(--color-accent-primary); - border: $b-1 solid var(--color-accent-primary); - inline-size: fit-content; - padding: var(--sp-xxs) var(--sp-s); - border-radius: $br-4; -} - .title { display: flex; flex-direction: row; diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 005dc3b908..93ec62f6ed 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -2536,10 +2536,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "Servidor MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Beta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Nom" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2e790e4f14..93833b420b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2674,10 +2674,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "MCP Server" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Beta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Name" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 3da6305f9e..cb3762d9c6 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2718,10 +2718,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "Servidor MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Beta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Nombre" diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index a8c6460fba..dc3e411849 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -2491,10 +2491,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "سرور MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "بتا" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "نام" diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 63c56a4fc0..48be009a0e 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -2567,10 +2567,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "Serveur MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Bêta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Nom" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index e5b34259e6..d1c29f7937 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -2356,10 +2356,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "Serveur MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Bêta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Nom" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index 38e7e83532..98a0e086d9 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -2240,10 +2240,6 @@ msgstr "התוקף של מפתח MCP המשמש לחיבור לשרת MCP פג. msgid "integrations.mcp-server.title" msgstr "שרת MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "בטא" - #: src/app/main/ui/dashboard/comments.cljs:94 msgid "label.mark-all-as-read" msgstr "לסמן הכול כנקרא" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 3fa5b6d7b9..f4aa8a2b80 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -2265,10 +2265,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "Server MCP" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Beta" - #: src/app/main/ui/settings/integrations.cljs:392 msgid "integrations.no-expiration" msgstr "Nessuna data di scadenza" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 65ee25f35b..9345b9a823 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -2438,10 +2438,6 @@ msgstr "MCPキーを再生成し、新しいキーでクライアントの設定 msgid "integrations.mcp-server.title" msgstr "MCPサーバー" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "ベータ" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "名前" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 678e93c364..ab18ddbcc4 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -2538,10 +2538,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "MCP-server" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Bèta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Naam" diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index af06c34ea8..0107d00ee9 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -2541,10 +2541,6 @@ msgstr "Перевыпустите ключ и обновите настройк msgid "integrations.mcp-server.title" msgstr "MCP-сервер" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Бета" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Имя" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index c749f67233..89e124f0f0 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -2511,10 +2511,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "MCP-server" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Beta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Namn" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index 853e76ab7a..f01e067cc2 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -2508,10 +2508,6 @@ msgstr "" msgid "integrations.mcp-server.title" msgstr "MCP Sunucusu" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "Beta" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "Ad" diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index f17366656f..d071281afc 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -2086,10 +2086,6 @@ msgstr "请重新生成 MCP 密钥,并在客户端配置中更新为新密钥 msgid "integrations.mcp-server.title" msgstr "MCP 服务器" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "测试版" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "名称" diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index 56f41a99d3..5fdd90d364 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -2370,10 +2370,6 @@ msgstr "請重新產生 MCP 金鑰,並使用新金鑰更新用戶端設定。" msgid "integrations.mcp-server.title" msgstr "MCP 伺服器" -#: src/app/main/ui/settings/integrations.cljs:493 -msgid "integrations.mcp-server.title.beta" -msgstr "測試版" - #: src/app/main/ui/settings/integrations.cljs:200 msgid "integrations.name.label" msgstr "名稱" From 4b2061f399c6e664efbdecba9f4eca43c389836a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Wed, 4 Nov 2026 12:04:52 +0100 Subject: [PATCH 222/325] :sparkles: Add unit tests for the token forms and token tree (#12781) * :sparkles: Add unit tests for the token form and combobox helpers Several helpers of the token forms and the token value combobox were already pure but had no tests. Cover the reference under the cursor, the order of the token tree, moving through the combobox options, the shadow and typography form values and their validation, which value a form tab validates, and that a token import needs a json file. Signed-off-by: Florian Schroedl * :sparkles: Test the shadow and typography form rules Add tests for the shadow and typography form schemas: a negative blur is rejected, a negative spread and a blank blur or spread are accepted, every shadow needs its offsets and color, a line height needs a font size, and a token can't reference itself. A typography token submitted without any field is rejected. They replace the tests of the validator wrappers, the combobox focus order and the import file check. Signed-off-by: Florian Schroedl --------- Signed-off-by: Florian Schroedl --- frontend/test/frontend_tests/runner.cljs | 8 ++ .../tokens/shadow_form_test.cljs | 105 ++++++++++++++++++ .../tokens/token_parsing_test.cljs | 46 ++++++++ .../tokens/token_tree_test.cljs | 37 ++++++ .../tokens/typography_form_test.cljs | 99 +++++++++++++++++ 5 files changed, 295 insertions(+) create mode 100644 frontend/test/frontend_tests/tokens/shadow_form_test.cljs create mode 100644 frontend/test/frontend_tests/tokens/token_parsing_test.cljs create mode 100644 frontend/test/frontend_tests/tokens/token_tree_test.cljs create mode 100644 frontend/test/frontend_tests/tokens/typography_form_test.cljs diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 63fee9982b..70b1ef0a47 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -112,11 +112,15 @@ [frontend-tests.tokens.logic.tokens-source-test] [frontend-tests.tokens.logic.tokens-status-test] [frontend-tests.tokens.referenced-token-value-test] + [frontend-tests.tokens.shadow-form-test] [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] + [frontend-tests.tokens.token-parsing-test] [frontend-tests.tokens.token-path-test] [frontend-tests.tokens.token-pill-test] + [frontend-tests.tokens.token-tree-test] [frontend-tests.tokens.tokenscript-test] + [frontend-tests.tokens.typography-form-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] [frontend-tests.ui.color-token-position-test] @@ -285,11 +289,15 @@ 'frontend-tests.tokens.logic.token-data-test 'frontend-tests.tokens.logic.token-remapping-test 'frontend-tests.tokens.referenced-token-value-test + 'frontend-tests.tokens.shadow-form-test 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test + 'frontend-tests.tokens.token-parsing-test 'frontend-tests.tokens.token-path-test 'frontend-tests.tokens.token-pill-test + 'frontend-tests.tokens.token-tree-test 'frontend-tests.tokens.tokenscript-test + 'frontend-tests.tokens.typography-form-test 'frontend-tests.tokens.logic.tokens-source-test 'frontend-tests.tokens.logic.tokens-status-test 'frontend-tests.tokens.workspace-tokens-remap-test diff --git a/frontend/test/frontend_tests/tokens/shadow_form_test.cljs b/frontend/test/frontend_tests/tokens/shadow_form_test.cljs new file mode 100644 index 0000000000..f940620095 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/shadow_form_test.cljs @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.shadow-form-test + (:require + [app.common.schema :as sm] + [app.common.schema.messages :as csm] + [app.common.types.tokens-lib :as ctob] + [app.main.data.workspace.tokens.errors :as wte] + [app.main.ui.workspace.tokens.management.forms.shadow :as wtsh] + [app.util.i18n :refer [tr]] + [cljs.test :as t :include-macros true])) + +(def ^:private make-schema @#'wtsh/make-schema) +(def ^:private form-value @#'wtsh/make-default-value) +(def ^:private check-self-reference @#'wtsh/check-shadow-token-self-reference) + +(def ^:private shadow + (assoc @#'wtsh/default-token-shadow :color "#000000")) + +(def ^:private token + (ctob/make-token :name "shadow.md" :type :shadow :value [shadow])) + +(defn- schema + [tab] + (make-schema (ctob/get-token-path token) + (ctob/tokens-tree {(:name token) token}) + tab)) + +(defn- form-data + [value] + {:name (:name token) :description "" :value value}) + +(defn- shadows + [& shadows] + (form-data {:reference nil :shadow (vec shadows)})) + +(defn- errors + [tab data] + (csm/collect-schema-errors (schema tab) [] {:data data})) + +(defn- valid? + [tab data] + (sm/validate (schema tab) data)) + +(t/deftest composite-tab + (t/testing "a complete shadow is valid" + (t/is (valid? :composite (shadows shadow)))) + + (t/testing "blur can't be negative" + (t/is (= (tr "workspace.tokens.shadow-token-blur-value-error") + (get-in (errors :composite (shadows (assoc shadow :blur "-2"))) + [:value :shadow 0 :blur :message])))) + + (t/testing "spread can be negative" + (t/is (valid? :composite (shadows (assoc shadow :spread "-2"))))) + + (t/testing "blur and spread can stay blank" + (t/is (valid? :composite (shadows (assoc shadow :blur "" :spread ""))))) + + (t/testing "every shadow needs its offsets and color" + (t/is (not (valid? :composite (shadows shadow (assoc shadow :color ""))))) + (t/is (not (valid? :composite (shadows (assoc shadow :offset-x ""))))) + (t/is (contains? (errors :composite (shadows (dissoc shadow :color))) :value)))) + +(t/deftest reference-tab + (t/testing "a reference is required" + (t/is (not (valid? :reference (form-data {:reference "" :shadow []})))) + (t/is (valid? :reference (form-data {:reference "{shadow.sm}" :shadow []})))) + + (t/testing "the token can't reference itself" + (t/is (= (tr "errors.tokens.self-reference") + (get-in (errors :reference (form-data {:reference "{shadow.md}" :shadow []})) + [:value :reference :message]))))) + +(t/deftest composite-self-reference + (t/testing "the error names the shadow and field that reference the token" + (t/is (= (assoc (wte/get-error-code :error.token/direct-self-reference) + :shadow-key :color + :shadow-index 1) + (check-self-reference + {:name "shadow.md" + :value [shadow (assoc shadow :color "{shadow.md}")]})))) + + (t/testing "references to other tokens pass" + (t/is (nil? (check-self-reference + {:name "shadow.md" + :value [(assoc shadow :color "{color.black}")]}))))) + +(t/deftest initial-form-value + (t/testing "a reference opens on the reference tab" + (t/is (= {:reference "{shadow.sm}" :shadow []} + (form-value "{shadow.sm}")))) + + (t/testing "blank or missing blur and spread show as 0" + (t/is (= {:reference nil :shadow [(assoc shadow :blur "0" :spread "0")]} + (form-value [(assoc shadow :blur "" :spread " ")]) + (form-value [(dissoc shadow :blur :spread)])))) + + (t/testing "a new token starts with one default shadow" + (t/is (= {:reference nil :shadow [@#'wtsh/default-token-shadow]} + (form-value nil))))) diff --git a/frontend/test/frontend_tests/tokens/token_parsing_test.cljs b/frontend/test/frontend_tests/tokens/token_parsing_test.cljs new file mode 100644 index 0000000000..b37e8fe663 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/token_parsing_test.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.token-parsing-test + (:require + [app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp] + [cljs.test :as t :include-macros true])) + +(t/deftest extract-partial-token + (t/testing "an open reference at the cursor" + (t/is (= {:start 0 :end 4 :partial "col"} + (tp/extract-partial-token "{col" 4)))) + + (t/testing "the last open reference when an earlier one is closed" + (t/is (= {:start 14 :end 17 :partial "sp"} + (tp/extract-partial-token "{color.red} + {sp" 17)))) + + (t/testing "the reference ends at its closing brace after the cursor" + (t/is (= {:start 0 :end 5 :partial "co"} + (tp/extract-partial-token "{colo}" 3)))) + + (t/testing "nothing after a closed reference" + (t/is (nil? (tp/extract-partial-token "{color} " 8)))) + + (t/testing "nothing without a reference" + (t/is (nil? (tp/extract-partial-token "12" 2))) + (t/is (nil? (tp/extract-partial-token "" 0))))) + +(t/deftest token-at-cursor + (t/testing "the token name when the cursor is inside a reference" + (t/is (= "color.red" (tp/token-at-cursor "{color.red}" 3))) + (t/is (= "color.red" (tp/token-at-cursor "{color.red} + {spacing.md}" 1))) + (t/is (= "spacing.md" (tp/token-at-cursor "{color.red} + {spacing.md}" 18)))) + + (t/testing "nothing for a reference that isn't closed" + (t/is (nil? (tp/token-at-cursor "{color" 3)))) + + (t/testing "nothing for an empty reference or one with spaces" + (t/is (nil? (tp/token-at-cursor "{}" 1))) + (t/is (nil? (tp/token-at-cursor "{color red}" 3)))) + + (t/testing "nothing before the first reference" + (t/is (nil? (tp/token-at-cursor "1 + {color}" 1))))) diff --git a/frontend/test/frontend_tests/tokens/token_tree_test.cljs b/frontend/test/frontend_tests/tokens/token_tree_test.cljs new file mode 100644 index 0000000000..2fa83904a1 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/token_tree_test.cljs @@ -0,0 +1,37 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.token-tree-test + (:require + [app.common.path-names :as cpn] + [app.common.types.tokens-lib :as ctob] + [app.main.ui.workspace.tokens.management.token-tree :as wtt] + [cljs.test :as t :include-macros true])) + +(def ^:private sort-nodes @#'wtt/sort-nodes) + +(defn- tree + [& names] + (cpn/build-tree-root + (map #(ctob/make-token :name % :type :spacing :value "4") names) + ctob/token-separator)) + +(defn- sorted-names + [nodes] + (map :name (sort-nodes nodes))) + +(t/deftest sort-nodes-test + (t/testing "tokens come before groups" + (t/is (= ["zeta" "alpha" "beta"] + (sorted-names (tree "alpha.sm" "zeta" "beta.md"))))) + + (t/testing "numbers in names sort by value" + (t/is (= ["size2" "size10" "group2" "group10"] + (sorted-names (tree "size10" "group10.sm" "size2" "group2.sm"))))) + + (t/testing "a token that also has tokens below it sorts with the groups" + (t/is (= ["md" "space"] + (sorted-names (tree "space.sm" "md" "space")))))) diff --git a/frontend/test/frontend_tests/tokens/typography_form_test.cljs b/frontend/test/frontend_tests/tokens/typography_form_test.cljs new file mode 100644 index 0000000000..64cc06be21 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/typography_form_test.cljs @@ -0,0 +1,99 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.typography-form-test + (:require + [app.common.schema :as sm] + [app.common.schema.messages :as csm] + [app.common.types.tokens-lib :as ctob] + [app.main.data.workspace.tokens.errors :as wte] + [app.main.ui.workspace.tokens.management.forms.typography :as wtty] + [app.util.i18n :refer [tr]] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +(def ^:private make-schema @#'wtty/make-schema) +(def ^:private form-value @#'wtty/composite-value->form-value) +(def ^:private check-self-reference @#'wtty/check-typography-token-self-reference) +(def ^:private validate-token @#'wtty/validate-typography-token) + +(def ^:private token + (ctob/make-token :name "type.body" :type :typography :value {:font-size "16"})) + +(defn- schema + [tab] + (make-schema (ctob/get-token-path token) + (ctob/tokens-tree {(:name token) token}) + tab)) + +(defn- form-data + [value] + {:name (:name token) :description "" :value value}) + +(defn- errors + [tab data] + (csm/collect-schema-errors (schema tab) [] {:data data})) + +(defn- valid? + [tab data] + (sm/validate (schema tab) data)) + +(t/deftest composite-tab + (t/testing "one field is enough" + (t/is (valid? :composite (form-data {:font-size "16"})))) + + (t/testing "at least one field must be set" + (t/is (not (valid? :composite (form-data {})))) + (t/is (not (valid? :composite (form-data {:font-size "" :font-weight ""}))))) + + (t/testing "a line height needs a font size" + (t/is (= (tr "errors.tokens.composite-line-height-needs-font-size") + (get-in (errors :composite (form-data {:line-height "1.5"})) + [:value :line-height :message]))) + (t/is (valid? :composite (form-data {:line-height "1.5" :font-size "16"}))))) + +(t/deftest reference-tab + (t/testing "a reference is required" + (t/is (not (valid? :reference (form-data {:reference ""})))) + (t/is (valid? :reference (form-data {:reference "{type.heading}"})))) + + (t/testing "the token can't reference itself" + (t/is (= (tr "errors.tokens.self-reference") + (get-in (errors :reference (form-data {:reference "{type.body}"})) + [:value :reference :message]))))) + +(t/deftest composite-self-reference + (let [self-reference (wte/get-error-code :error.token/direct-self-reference)] + (t/testing "the error names the field that references the token" + (t/is (= (assoc self-reference :typography-key :font-size) + (check-self-reference {:name "type.body" :value {:font-size "{type.body}"}})))) + + (t/testing "each font family is checked" + (t/is (= (assoc self-reference :typography-key :font-family) + (check-self-reference {:name "type.body" :value {:font-family ["Inter" "{type.body}"]}})))) + + (t/testing "references to other tokens pass" + (t/is (nil? (check-self-reference {:name "type.body" :value {:font-size "{font.size.md}"}})))))) + +(t/deftest submit-without-fields + (t/async + done + (->> (validate-token {:token-name "type.body" :token-value nil :tokens {}}) + (rx/subs! (fn [_] + (t/is false "a token without fields must be rejected, not saved") + (done)) + (fn [error] + (t/is (= {:errors [(wte/get-error-code :error.token/empty-input)]} error)) + (done)))))) + +(t/deftest initial-form-value + (t/testing "font families show as one comma separated field" + (t/is (= {:font-family "Inter, Arial" :font-size "16"} + (form-value {:font-family ["Inter" "Arial"] :font-size "16"})))) + + (t/testing "only the typography fields are kept" + (t/is (= {:line-height "1.5"} + (form-value {:line-height "1.5" :paragraph-spacing "4"}))))) From 177c3c6551429a190c6b7dfa248001ff4d7d898b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Wed, 4 Nov 2026 12:05:38 +0100 Subject: [PATCH 223/325] :wrench: Add one more test case (#12827) --- frontend/test/frontend_tests/tokens/token_pill_test.cljs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/test/frontend_tests/tokens/token_pill_test.cljs b/frontend/test/frontend_tests/tokens/token_pill_test.cljs index 24476b91c0..9e98a2c84e 100644 --- a/frontend/test/frontend_tests/tokens/token_pill_test.cljs +++ b/frontend/test/frontend_tests/tokens/token_pill_test.cljs @@ -181,6 +181,14 @@ (t/is (= {:message (tr "workspace.tokens.value-not-valid")} (tooltip radius [] :tokens {"radius.md" (assoc radius :errors [error])})))))) + (t/testing "a resolved error message replaces the generic one" + (let [error {:error/code :error.token/name-collision + :error/value "radius.md" + :error/fn (fn [value] (str "Collides: " value))} + tokens {"radius.md" (assoc radius :errors [error])}] + (t/is (= {:message "Collides: radius.md"} + (tooltip radius [] :tokens tokens))))) + (t/testing "a valid token shows its value and resolved value" (t/is (= {:name "radius.md" :value "8" From 2573908315996adc597b85bcc69c1c6b23359da1 Mon Sep 17 00:00:00 2001 From: Luke Lau Date: Wed, 4 Nov 2026 20:00:24 +0800 Subject: [PATCH 224/325] :zap: Drop unused used-names sets from component duplication (#12809) unames or update-unames! are no longer used after a1a3d09998, and each call to cfh/get-used-names is a scan across all objects --- common/src/app/common/logic/libraries.cljc | 16 +++++----------- common/src/app/common/types/container.cljc | 4 ---- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a7296ff5d5..1b7f2488f0 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -3122,12 +3122,12 @@ changes)) (defn generate-duplicate-shape-change - ([changes objects page unames update-unames! ids ids-map obj delta level-delta libraries library-data file-id] - (generate-duplicate-shape-change changes objects page unames update-unames! ids ids-map obj delta level-delta libraries library-data file-id (:frame-id obj) (:parent-id obj) false false true nil)) - ([changes objects page unames update-unames! ids ids-map obj delta level-delta libraries library-data file-id variant-props] - (generate-duplicate-shape-change changes objects page unames update-unames! ids ids-map obj delta level-delta libraries library-data file-id (:frame-id obj) (:parent-id obj) false false true variant-props)) + ([changes objects page ids ids-map obj delta level-delta libraries library-data file-id] + (generate-duplicate-shape-change changes objects page ids ids-map obj delta level-delta libraries library-data file-id (:frame-id obj) (:parent-id obj) false false true nil)) + ([changes objects page ids ids-map obj delta level-delta libraries library-data file-id variant-props] + (generate-duplicate-shape-change changes objects page ids ids-map obj delta level-delta libraries library-data file-id (:frame-id obj) (:parent-id obj) false false true variant-props)) - ([changes objects page unames update-unames! ids ids-map obj delta level-delta libraries library-data file-id frame-id parent-id duplicating-component? child? remove-swap-slot? variant-props] + ([changes objects page ids ids-map obj delta level-delta libraries library-data file-id frame-id parent-id duplicating-component? child? remove-swap-slot? variant-props] (cond (nil? obj) changes @@ -3240,8 +3240,6 @@ (generate-duplicate-shape-change changes (:objects page') page - unames - update-unames! ids ids-map child @@ -3268,8 +3266,6 @@ move to the desired position, and recalculate parents and frames as needed." [changes all-objects page ids delta libraries library-data file-id & {:keys [variant-props alt-duplication?]}] (let [shapes (map (d/getf all-objects) ids) - unames (volatile! (cfh/get-used-names (:objects page))) - update-unames! (fn [new-name] (vswap! unames conj new-name)) all-ids (reduce #(into %1 (cons %2 (cfh/get-children-ids all-objects %2))) (d/ordered-set) ids) ;; We need ids-map for remapping the grid layout. But when duplicating the guides @@ -3299,8 +3295,6 @@ (reduce #(generate-duplicate-shape-change %1 all-objects page - unames - update-unames! ids ids-map %2 diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index cc7ea9bd07..6f5bde52a7 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -324,7 +324,6 @@ delta (gpt/subtract position orig-pos) objects (:objects page) - unames (volatile! (cfh/get-used-names objects)) component-children (d/index-by :id (cfh/get-children-with-self objects (:id component-shape))) @@ -353,9 +352,6 @@ (let [new-name (:name new-shape) root? (ctk/instance-root? original-shape)] - (when root? - (vswap! unames conj new-name)) - (vswap! ids-map assoc (:id original-shape) (:id new-shape)) (cond-> new-shape From 8d7e275b6bae14d8e8899da81efd7f6514bb1e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 4 Nov 2026 13:01:05 +0100 Subject: [PATCH 225/325] :bug: Count id and type in the wasm shape proxy (#12793) The proxy counted one entry fewer than it yields, so it was never equal to a plain map with the same entries. Count both fields and test it. --- frontend/src/app/render_wasm/shape.cljs | 2 +- .../render_wasm/shape_proxy_test.cljs | 24 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 ++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 frontend/test/frontend_tests/render_wasm/shape_proxy_test.cljs diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 1d2980d6c9..dfbcf37bb4 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -77,7 +77,7 @@ c/ICounted (-count [_] - (+ 1 (count delegate))) + (+ 2 (count delegate))) c/IKVReduce (-kv-reduce [_ f init] diff --git a/frontend/test/frontend_tests/render_wasm/shape_proxy_test.cljs b/frontend/test/frontend_tests/render_wasm/shape_proxy_test.cljs new file mode 100644 index 0000000000..276be80d53 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/shape_proxy_test.cljs @@ -0,0 +1,24 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-wasm.shape-proxy-test + (:require + [app.common.uuid :as uuid] + [app.render-wasm.shape :as wasm.shape] + [cljs.test :as t :include-macros true])) + +(def ^:private attrs + {:id (uuid/next) :type :rect :name "Rect" :x 10 :y 20}) + +(t/deftest count-includes-id-and-type + (let [shape (wasm.shape/create-shape attrs)] + (t/is (= (count attrs) (count shape))) + (t/is (= (count shape) (count (seq shape)))))) + +(t/deftest equals-plain-map-with-same-entries + (let [shape (wasm.shape/create-shape attrs)] + (t/is (= shape attrs)) + (t/is (= attrs shape)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 70b1ef0a47..c0fee67ec6 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -94,6 +94,7 @@ [frontend-tests.render-dimensions-test] [frontend-tests.render-wasm.process-objects-test] [frontend-tests.render-wasm.serialization-test] + [frontend-tests.render-wasm.shape-proxy-test] [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.render-wasm.text-editor-content-range-test] @@ -273,6 +274,7 @@ 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test 'frontend-tests.render-wasm.serialization-test + 'frontend-tests.render-wasm.shape-proxy-test 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.render-wasm.text-editor-content-range-test From 757ec974df21647ed1ad869228e55c1731d96d6f Mon Sep 17 00:00:00 2001 From: Juanfran Date: Wed, 4 Nov 2026 13:03:10 +0100 Subject: [PATCH 226/325] :bug: Build plugin scripts before Angular copies assets (#12815) Generate plugin.js before Angular builds each of the eight Angular plugins. The previous order wrote the script to src/assets after Angular had copied assets to dist, so clean deploys published manifests without their code. Verified clean builds include all declared manifest assets for all eight plugins. Plugin lint, format checks and all 101 tests pass. AI-assisted-by: gpt-6.1-sol --- plugins/apps/colors-to-tokens-plugin/package.json | 2 +- plugins/apps/contrast-plugin/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- plugins/apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index 4c9690e2a2..f46efc11c4 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build colors-to-tokens-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build colors-to-tokens-plugin", "build:dev": "ng build colors-to-tokens-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=colors-to-tokens-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=colors-to-tokens-plugin --watch", diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 641618631d..13f6ca0b4f 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build contrast-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build contrast-plugin", "build:dev": "ng build contrast-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=contrast-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=contrast-plugin --watch", diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 31f25c5e8a..59f40ad593 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build icons-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build icons-plugin", "build:dev": "ng build icons-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=icons-plugin", "build:plugin:watch": "node ../../tools/scripts/build-plugin.mjs --plugin=icons-plugin --watch", diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index f5d5be5541..ac115f61c6 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build lorem-ipsum-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build lorem-ipsum-plugin", "build:dev": "ng build lorem-ipsum-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=lorem-ipsum-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=lorem-ipsum-plugin --watch", diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index df5bc13b27..d86158f9d4 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build poc-state-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build poc-state-plugin", "build:dev": "ng build poc-state-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-state-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-state-plugin --watch", diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 41337275df..62fa1c69fe 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build poc-tokens-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build poc-tokens-plugin", "build:dev": "ng build poc-tokens-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-tokens-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=poc-tokens-plugin --watch", diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index 38eb1e0939..aa15dd3551 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build rename-layers-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build rename-layers-plugin", "build:dev": "ng build rename-layers-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=rename-layers-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=rename-layers-plugin --watch", diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index f2bc2d696b..3b930e3ab5 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "ng build table-plugin && pnpm run build:plugin", + "build": "pnpm run build:plugin && ng build table-plugin", "build:dev": "ng build table-plugin --configuration development", "build:plugin": "node ../../tools/scripts/build-plugin.mjs --plugin=table-plugin", "watch": "node ../../tools/scripts/build-plugin.mjs --plugin=table-plugin --watch", From f532b8c12eaad66f4aa101ce919f399add9a513d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 4 Nov 2026 14:01:27 +0100 Subject: [PATCH 227/325] :sparkles: Add get-profiles-by-ids management method (#12832) --- backend/src/app/rpc/management/nitrate.clj | 27 ++++++++++++++++++ .../rpc_management_nitrate_test.clj | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 5199945d70..84cf2630ea 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -540,6 +540,33 @@ RETURNING id, deleted_at;") (profile-to-map profile))) +;; ---- API: get-profiles-by-ids + +(def ^:private sql:get-profiles-by-ids + "SELECT DISTINCT id, fullname, email, photo_id + FROM profile + WHERE id = ANY(?) + AND deleted_at IS NULL;") + +(def ^:private schema:get-profiles-by-ids-params + [:map [:ids [:vector ::sm/uuid]]]) + +(sv/defmethod ::get-profiles-by-ids + "Get multiple profiles by id. Unknown ids are simply absent from the result." + {::doc/added "2.20" + ::rpc/allowed-shared-keys allowed-shared-key-ids + ::sm/params schema:get-profiles-by-ids-params + ::sm/result [:vector schema:profile] + ::nitrate/sso false} + [cfg {:keys [ids]}] + (if (empty? ids) + [] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" ids)] + (->> (db/exec! conn [sql:get-profiles-by-ids ids-array]) + (mapv profile-to-map))))))) + + ;; ---- API: get-organization-member-team-counts (def ^:private sql:get-organization-member-team-counts diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index cdef65b340..abf83b4b2a 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -824,6 +824,34 @@ (t/is (= :not-found (th/ex-type (:error ko-out)))) (t/is (= :profile-not-found (th/ex-code (:error ko-out))))))) +(t/deftest get-profiles-by-ids-existing-only-deduped-and-empty + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile-a (th/create-profile* 1 {:is-active true + :fullname "Batch A"}) + profile-b (th/create-profile* 2 {:is-active true + :fullname "Batch B"}) + unknown (uuid/random) + params {::th/type :get-profiles-by-ids + ::rpc/profile-id (:id profile-a)} + out (th/management-command! (assoc params :ids [(:id profile-a) + (:id profile-b) + unknown])) + dup-out (th/management-command! (assoc params :ids [(:id profile-a) + (:id profile-a)])) + empty-out (th/management-command! (assoc params :ids []))] + (t/is (th/success? out)) + (t/is (= #{(:id profile-a) (:id profile-b)} + (set (map :id (:result out))))) + (t/is (= #{"Batch A" "Batch B"} + (set (map :name (:result out))))) + (t/is (every? #(contains? % :email) (:result out))) + + (t/is (th/success? dup-out)) + (t/is (= 1 (count (:result dup-out)))) + + (t/is (th/success? empty-out)) + (t/is (= [] (:result empty-out)))))) + (t/deftest get-organization-invitations-returns-valid-deduped-by-email ;; --- Deferred organization-summary: nil during setup, filled before RPC --- (let [organization-summary-ref (atom nil)] From 48cb2a83b4b009f57fef6510154682a22d69e58f Mon Sep 17 00:00:00 2001 From: Alexis Morin Date: Tue, 3 Nov 2026 14:41:37 +0100 Subject: [PATCH 228/325] :globe_with_meridians: Add translations for: French (Canada) Currently translated at 96.9% (2205 of 2275 strings) Translation: Penpot/frontend Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/ --- frontend/translations/fr_CA.po | 68 +++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 499fd96ce2..ec0e685529 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-11-03 02:51+0000\n" +"PO-Revision-Date: 2026-11-04 13:51+0000\n" "Last-Translator: Alexis Morin \n" "Language-Team: French (Canada) \n" @@ -9238,3 +9238,69 @@ msgstr[1] "%s bibliothèques ont été liées automatiquement par nom." #: src/app/main/ui/dashboard/import.cljs:733 msgid "dashboard.import.confirm-library-links" msgstr "Confirmer les liens de bibliothèque" + +#: src/app/main/ui/dashboard/import.cljs:709 +msgid "dashboard.import.connect-selected-libraries" +msgstr "Connecter les bibliothèques sélectionnées" + +#: src/app/main/ui/dashboard/import.cljs:296 +msgid "dashboard.import.file-name-label" +msgstr "Nom du fichier" + +#: src/app/main/ui/dashboard/import.cljs:710 +msgid "dashboard.import.next-file" +msgstr "Fichier suivant" + +#: src/app/main/ui/dashboard/import.cljs:443 +msgid "dashboard.import.resolve-libraries" +msgstr "" +"Certaines bibliothèques n'ont pu être liées automatiquement. Sélectionner la " +"bonne bibliothèque pour chacun :" + +#: src/app/main/ui/dashboard/import.cljs:565 +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Réviser les liens de bibliothèque avant de confirmer :" + +#: src/app/main/ui/dashboard/import.cljs:459 +msgid "dashboard.import.resolve-libraries.connect-to" +msgstr "connecter à" + +#: src/app/main/ui/dashboard/import.cljs:453 +msgid "dashboard.import.resolve-libraries.original-library" +msgstr "bibliothèque originale" + +#: src/app/main/ui/dashboard/import.cljs:521 +msgid "dashboard.import.summary.linked" +msgstr "liée automatiquement" + +#: src/app/main/ui/dashboard/import.cljs:529 +msgid "dashboard.import.summary.manually-linked" +msgstr "liée manuellement" + +#: src/app/main/ui/dashboard/import.cljs:537 +msgid "dashboard.import.summary.new" +msgstr "Nouveau" + +#: src/app/main/ui/dashboard/import.cljs:558 +msgid "dashboard.import.summary.no-selection" +msgstr "Aucune bibliothèque sélectionnée" + +#: src/app/main/ui/dashboard/import.cljs:534 +msgid "dashboard.import.summary.original" +msgstr "Original" + +#: src/app/main/ui/dashboard/team.cljs:152 +msgid "dashboard.invite-profile-disabled" +msgstr "Tu n'as pas la permission d'inviter des gens à cette équipe" + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:185 +msgid "dashboard.no-organizations-yet" +msgstr "Aucune organisation." + +#: src/app/main/ui/dashboard/organization_team_switch.cljs:208, src/app/main/ui/dashboard/organization_team_switch.cljs:209 +msgid "dashboard.other-teams" +msgstr "Autres équipes" + +#: src/app/main/ui/dashboard/file_menu.cljs:38, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:58, src/app/main/ui/dashboard/organization_team_switch.cljs:57, src/app/main/ui/dashboard/projects.cljs:50, src/app/main/ui/dashboard/projects.cljs:364, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/team.cljs:647, src/app/main/ui/dashboard/team.cljs:1257, src/app/main/ui/dashboard/team.cljs:1537, src/app/main/ui/dashboard/team.cljs:1644 +msgid "dashboard.personal-projects" +msgstr "Projets personnels" From 2f060e9baf8e4e8051371265ec9f6ae491784580 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 4 Nov 2026 15:29:07 +0100 Subject: [PATCH 229/325] :sparkles: Add skills for the DS migration epic (#12739) * :sparkles: Add skills for the DS migration epic Two skills that drive the DS migration (#12375) one area at a time, so anyone working with an agent follows the same flow. refactor-scss moves one .scss off frontend/resources/styles/common/ refactor/ to the DS tokens, mixins and px2rem as a pure refactor, with a self-review for the issues reviewers flag and a zero-tolerance visual check. migrate-ds-area takes one area of a group issue (buttons and icons, selects, ...): claim it in the group's PRs table, add a visual test, change the controls, run refactor-scss, compare before and after with the bundled diff scripts, then open the draft PR and post the QA comment. It also lists devenv traps seen while migrating the first areas. AI-assisted-by: claude-opus-5-5 * :sparkles: Add the claim rules to the migrate-ds-area skill The group issue body is saved whole, so two people claiming at the same time can lose a row. The skill now rereads the body after saving and puts the row back if it is gone. A claim with no PR for 7 days is free once its owner has been asked in a comment and did not answer. AI-assisted-by: claude-opus-5-5 --- .agents/skills/migrate-ds-area/SKILL.md | 330 ++++++++++++++ .../skills/migrate-ds-area/scripts/diff.py | 25 ++ .../skills/migrate-ds-area/scripts/trip.py | 33 ++ .agents/skills/refactor-scss/SKILL.md | 421 ++++++++++++++++++ 4 files changed, 809 insertions(+) create mode 100644 .agents/skills/migrate-ds-area/SKILL.md create mode 100644 .agents/skills/migrate-ds-area/scripts/diff.py create mode 100644 .agents/skills/migrate-ds-area/scripts/trip.py create mode 100644 .agents/skills/refactor-scss/SKILL.md diff --git a/.agents/skills/migrate-ds-area/SKILL.md b/.agents/skills/migrate-ds-area/SKILL.md new file mode 100644 index 0000000000..d06d40a4f5 --- /dev/null +++ b/.agents/skills/migrate-ds-area/SKILL.md @@ -0,0 +1,330 @@ +--- +name: migrate-ds-area +description: Migrate one Penpot UI area to the Design System components for one group of the DS migration epic (#12375): buttons and icons (icon-button*, button*, icon*), selects (select*) and later groups. Moves the area's SCSS off the deprecated styles, proves every change with a zero-tolerance visual test, then opens the draft PR and updates the group issue (PRs table row, QA comment). Use when the user asks to migrate an area or module to the DS, continue a DS migration group, or names an area of a group issue. +--- + +# Skill: migrate-ds-area + +Move one UI area (one `.cljs` + `.scss` pair, or a few related modules) +from legacy components and raw HTML to the Design System components, +prove with pixels what changed, and ship it as a draft PR that QA can +test. + +The epic is #12375. Each control type is a group with its own issue +(buttons and icons #12376, selects #12377, radios #12378, …). The group +issue holds the plan (order, waiting areas), the changes QA should +expect, and the PRs table where people claim areas. Read it first. + +## Input + +- The group issue (e.g. `#12376`) and the area: a module path (e.g. + `dashboard/fonts`) or an area named in the group issue. +- No area given: take the next unclaimed area in the group issue's plan + order, and say which one. + +## Ground rules + +- Never `git push`. The user pushes; then you open the PR. +- PRs go to `develop`; branches start from `origin/develop`. +- **Stay in the group.** Only the controls of this group change. In the + buttons/icons group, selects, radios, numeric/text inputs, checkboxes, + menus (`%menu-*`, `%dropdown-*`) and modal structure (`%modal-*`) stay; + a checkbox tick or an input label icon stays legacy. +- **Accept the DS look.** Do not add CSS to imitate legacy sizes or + colors. Do keep positions: icons and labels must not move unless the + DS size forces it, and then say so. +- No negative margins to compensate; use positive DS spacing or + `px2rem()`. +- **Gaps in the DS wait.** An area that needs an icon or a feature the + DS lacks keeps its legacy control; say so in the PR. +- Skip legacy shared components that another group will replace + (`context_menu_a11y`, `forms`, `select`, `dropdown`, `radio_buttons`, + `search_bar`, `title_bar`, …): they wait for that group's cleanup, + where they are updated if still used or deleted if not. +- Debug-only UI gets no QA comment and no compare image. + +## Devenv + +Run every test, lint and `pnpm` command inside the workspace container, +never on the host (a host `pnpm` relinks `node_modules` and breaks the +container's watcher). The container is `penpot-devenv-ws-main`, where +`` is the workspace number (`ws0` is the main repo; `wsN` lives in +`~/.penpot/penpot_workspaces/wsN`). Write output to a file, then read it: + +```bash +C=penpot-devenv-ws1-main # your workspace +docker exec -u penpot -w /home/penpot/penpot/frontend $C \ + bash -lc '' > /out.txt 2>&1 +``` + +When the app does not behave as the code says, check the build first: + +- **Stale build after a branch switch** ("is not a function", or a page + with empty `stl/css` classes): `touch` the stale `.cljs`/`.scss`, wait + for the compiled file under `frontend/resources/public/` to update, + hard-reload. +- **A whole area unstyled:** a new `.scss` added after the watcher + started is missing from `main.css` (grep `main.css` for the module + prefix), or `main.css` was written corrupted after many `.scss` + changed at once. Touch a `.scss`; if the module is still missing, + restart the watcher in the workspace's tmux session (`penpot:0`, + `./scripts/watch app`). +- **Blank app after switching between `develop` and `staging`:** + dependencies differ. Run `pnpm install --frozen-lockfile` in the + container and restart the watcher. + +## Workflow + +### 1. Claim the area and branch + +1. Read the group issue: plan, waiting areas, expected changes, PRs table. +2. Add a row to the PRs table with no PR number, the user's @login as + Owner and the state `Claimed` (`gh issue view/edit --body-file`). + Read the body right before editing, and read it again after saving: + when two people edit at the same time, GitHub keeps only the last + edit, so a row can vanish. Put yours back if it is gone. + Skip an area someone else already claimed, unless the claim has had + no PR for 7 days and its owner did not answer a comment asking. +3. `git switch -c -ds-- origin/develop`. +4. Survey the area: every control of the group in the `.cljs`, the + legacy icons, the placeholders and mixins the `.scss` uses, the + `div`/`span` with `:on-click`. + +### 2. Visual baseline + +Add a `test.describe(" controls", …)` block to the matching spec +in `frontend/playwright/ui/visual-specs/` (project `ds`, dark theme). +Put it next to the blocks of the same screen, not always at the end of +the file: two PRs that both append at the end conflict. + +- Every shot: `toHaveScreenshot({ maxDiffPixelRatio: 0, threshold: 0 })`. +- Show every changed control at rest, on hover and open or pressed. + Capture the element or the smallest stable container. +- Locators must work before and after the change: text, role, testid, + `[class*='…']`, or `a.or(b)` when the element type changes (raw + `[class*='menu-btn']` before, `getByRole("button", { name: "Options" })` + after). Scope `.or()` to the area so it cannot match elsewhere. +- Missing data? Add a fixture under `frontend/playwright/data/` and mock + it (`dashboardPage.mockRPC(...)`, `DashboardPage.mockConfigFlags` for + flags). Watch for expired or inconsistent fixtures (dates in the past, + wrong file for a mock). +- Capture with `--update-snapshots=all`: + ```bash + npx playwright test --project=ds -g "" --update-snapshots=all + ``` + Plain `--update-snapshots` keeps a snapshot within the project's 0.5% + tolerance, so a real diff looks like 0. +- Copy the snapshots (`.spec.js-snapshots/-*`) to + `/diffs//before/` and look at them: the state you + wanted must really be on screen. + +### 3. Controls commit + +#### Buttons and icons + +| Legacy | DS | +|---|---| +| icon-only raw button | `[:> icon-button* {:variant "ghost" :aria-label (tr …) :icon i/x :on-click f}]` (always pass `:variant`; `"action"` for 24px icon toggles) | +| pressed/selected | `:aria-pressed (boolean x)` | +| text button | `[:> button* {:variant "primary"/"secondary"/"ghost"/"destructive" :type "button"} label]` | +| `[:input {:type "button"}]` | `button*` | +| modal close | `icon-button*` ghost, `:aria-label (tr "labels.close")`, `:tooltip-class` carrying the corner position | +| legacy icon outside a button | `[:> icon* {:icon-id i/x :class …}]` | + +- Not every raw button changes: rows, tabs, swatches, avatar widgets, + full-height segments, round custom buttons and link-style text buttons + stay raw (their legacy icon still changes). List the kept ones in the + PR. +- A `div`/`span` with `:on-click` in the area becomes a `[:button]` + (with `aria-expanded`/`aria-haspopup` when it opens something). An + `[:a]` without `href` stays; list it in the PR. +- **Tooltip wrapper.** `icon-button*` renders inside a tooltip trigger + wrapper. Position (`position: absolute`, grid placement, margins + against siblings) goes on `:tooltip-class`; look (`--button-height`, + colors) goes on `:class`. +- **Tooltip at the right edge.** Small icon buttons at the right end of + a sidebar row or title bar get `:tooltip-placement "top-left"`; list + it as an expected change. +- **Sizes through custom properties:** `--button-height: #{px2rem(64)}` / + `--button-width`. Sass does not evaluate functions inside custom + properties without `#{}`. +- **A grown `button*` keeps its label at the start**: add + `justify-content: center` when it is wider than its content. +- **Icon colors:** DS icons use `stroke: currentColor`. Set `color` on + the icon class, not `stroke`/`fill`. A legacy `color: transparent` left + on the button or wrapper makes the DS icon invisible: drop it. A class + that redefines the color variable on the icon breaks the hover that + changes it on the button: define the variable on the button. +- **Every DS icon needs a color of its own** (on its class or a parent): + one that inherits none shows in the devenv debug yellow. +- **Icon sizes:** `"s"` draws 12px inside a 16px box (compensate the + margin by 2px per side to keep the drawing in place); `"m"` 16px; `"l"` + 24px; `"xl"` 32px. +- **Stroke width:** DS icons keep the DS stroke width. Never set + `stroke-width` on a DS icon to match the legacy one; a thinner or + thicker line is part of the DS look. +- **Legacy placeholders that style `svg` descendants** (e.g. + `%dropdown-element-base` → `span svg { 12px box; stroke }`) override a + DS icon placed inside them: give the icon class its own box + (`inline-size`/`block-size` 16px) and `stroke: currentcolor` with more + specificity. +- **Flex shrink:** a DS svg inside a small flex button can shrink and be + cut; add `flex-shrink: 0` to the icon class. +- **Rotated legacy arrows:** prefer `i/arrow-left`/`-right`/`-up`/`-down` + over rotating `i/arrow` (same path), unless a rotation class drives the + open/closed state. +- **Menus anchored by the button size** move when the button grows; open + them from the top (`inset-block-start: calc(100% + var(--sp-xs))`) or + fix the context menu offset, and say so. + +#### Selects + +`[:& select {:default-value v :options [{:value :label}] :on-change f}]` +→ `[:> select* {:default-selected :options :on-change f}]` +(`app.main.ui.ds.controls.select`). + +- **Ids are non-blank strings.** A blank id shows a dimmed "--", and an + id that matches no option falls back to the first one. Give mixed, + none and auto their own ids (`"multiple"`, `"none"`, `"auto"`) and map + them back in the handler. Use `""` only where "--" is the wanted look. +- **Convert the value.** The legacy select ran `d/read-string`, so + handlers got keywords, numbers or uuids; `select*` passes the `:id` + string. Convert with `keyword`, `d/parse-integer` or `uuid/parse`. +- **Memoized options, live selection.** `select*` resets its selection + when `:default-selected` or the `:options` identity changes. Build + options with `mf/with-memo` and `mapv` (the schema wants a non-empty + vector) and pass `:default-selected` from the live value, never a + constant. +- **Never pass nil.** In dev builds a present prop with value nil + (`:class`, `:wrapper-class`, `:disabled`, `:default-selected`) throws + `invalid props on component select*`. Use `(boolean x)`, `d/nilv`, or + add the prop only when it has a value. +- **Props the legacy select ignored stay out**: `select*` passes rest + props to its button, so they would reach the DOM. +- **Classes:** `:wrapper-class` for layout, `:class` for look. +- **Portal:** add `:has-portal true` only where the visual test shows the + list cut at the bottom of a panel, and say so in the QA comment. +- **Locators:** the button is a `combobox` with no accessible name; use + `getByRole("combobox").filter({ hasText: "Linear" })` (it matches the + legacy select too) and `getByRole("option", { name: … })`. +- **Keyboard:** the arrows no longer change the value while the list is + closed. It is an expected change, already listed in #12377. + +#### All groups + +- New labels: add the key to `en.po` and `es.po` by hand, in sorted + position, with a `#:` reference to the cljs line. Never run the + translations sync. +- The `.scss` only gets what the new controls need in this commit. +- Some e2e specs are not Prettier-clean on develop: never run + `prettier --write` on a whole spec; format only your block. + +Then wait for the watcher (grep the compiled JS/CSS under +`frontend/resources/public/` for the change), recapture with `=all`, +copy to `after/`, diff: + +```bash +SK=.agents/skills/migrate-ds-area/scripts +python3 -I $SK/diff.py # per-file pixel count + bbox +python3 -I $SK/trip.py # before | after | red diff +``` + +`trip.py` pads images of different sizes, so a taller "after" still +renders. +Explain every diff. Fix what is not a DS-look change (moved icons, cut +icons, lost hover color). Rerun a shot that changes on unchanged code +before trusting it; flaky spots seen so far: the invite modal textarea +border, the search input caret, the user account page, the path handler +menu trigger. + +Lint, then commit (read `mem:workflow/creating-commits` first): + +```bash +npx prettier --check ; npx stylelint ; clj-kondo --lint ; cljfmt check +git add -f # -f: visual-specs/ is gitignored +./scripts/check-commit +``` + +A plain `git add` with an ignored path in the list adds nothing; always +`-f`, and check `git show --stat`. Never commit the screenshots. +Subject: `:recycle: Use DS in `. A pre-existing bug +found and fixed on the way goes in its own `:bug:` commit. + +### 4. SCSS commit + +Run the `refactor-scss` skill on every `.scss` the PR touched (only +those), in its own commit. Keep `refactor/` only for component-family +placeholders (`%modal-*`, `%menu-*`, `%input-*`, `%checkbox-*`) and say +why in a comment above the `@use`. Unused selectors are deleted, not +commented out (list them in the commit body). Typography without an +exact match uses the closest DS typography. Run the skill's +**Self-review** before committing: no bare element selectors (add +classes in the `.cljs`), no copied properties that do nothing, z-index +tokens by purpose. Compare against the controls commit: it should be +0 px; explain anything else (typically a typography or border change). +For files that several modules share or that are huge, stop and propose +a separate PR instead. + +### 5. Hand off, PR and tracking + +1. Report to the user: commits, visible changes, kept raw controls, + pre-existing bugs, and the compare image path + (`/diffs//compare.png`, before | after | diff). Give + the push command: `git push -u origin ` (the branch tracks + `origin/develop`, so a bare `git push` is refused). +2. After the user pushes, check `git rev-parse HEAD origin/`, + then (read `mem:workflow/creating-prs` first): + ```bash + gh pr create --repo penpot/penpot --draft --base develop --head \ + --title "" --body-file \ + --label "WG adoption" --label "no issue required" \ + --milestone --project Main + gh pr edit --repo penpot/penpot --remove-label "needs triage" + ``` + Never add `no changelog` to a PR: that label goes on issues. + Body: AI note, `Part of # (DS migration epic #12375).`, a + short What / How, the kept raw controls, an empty `## Screenshots` + when something is visible (the user attaches the image). Never + `Closes #`. Keep it short: reviewers find long PRs hard to read. +3. In the group issue's PRs table, fill your row: + `| # | : () | @ | Draft |`. +4. Post a QA comment on the group issue: `**#: **`, *Where* (how + to reach it), *Expected changes* (every visible change; QA treats + anything not listed as a bug), *No other change* (what still works + the same). + +Do not mark the draft ready or ask for review: the user decides when. + +### 6. Review fixes + +When the reviewer comments on a PR: + +1. Fix each comment in a new commit on the branch (never amend pushed + commits). A comment you do not apply gets a short reason the user can + post, with an image when it is visual. +2. Rerun the area's visual spec before and after the fix with `=all`. + Build a compare image for every visible change and give the user its + full path, saying which PR it goes to. +3. Update everything the fix makes stale, in the same turn: the QA + comment (*Expected changes* must list the new visible changes), the + PRs table row, and the PR body. +4. When working on several PRs in one go, report per PR: branch, new + commit, what changed, image path. Never mix two branches in one list. +5. Give the push command per branch. If a rebase is coming soon, say + that pushing now costs one extra CI run; the user decides. +6. After a round of review fixes, check whether the same issue exists in + the user's other open PRs of the group, and fix it there too. + +### 7. After merges + +Whenever PRs merge (or go from draft to ready), sync the state column of +the group's PRs table with GitHub for **every** row, not only your own: +read each row's PR (`gh pr view --json state,isDraft`) and set +Merged / In review / Draft. The table drifts when other people merge. + +## Output + +- Branch with 2 commits (controls, SCSS) plus any `:bug:` commit. +- Visual spec block with zero-tolerance shots. +- Compare image in the scratch folder. +- After the push: draft PR, row and QA comment in the group issue. diff --git a/.agents/skills/migrate-ds-area/scripts/diff.py b/.agents/skills/migrate-ds-area/scripts/diff.py new file mode 100644 index 0000000000..9792f37d8a --- /dev/null +++ b/.agents/skills/migrate-ds-area/scripts/diff.py @@ -0,0 +1,25 @@ +"""Pixel diff of two folders of PNG snapshots with the same file names. + +Usage: python3 -I diff.py +Prints, per file, the number of differing pixels and their bounding box, +or the two sizes when they differ. +""" +import os +import sys + +from PIL import Image, ImageChops + +before, after = sys.argv[1], sys.argv[2] +for name in sorted(os.listdir(before)): + other = os.path.join(after, name) + if not os.path.exists(other): + print(name, "missing in", after) + continue + a = Image.open(os.path.join(before, name)).convert("RGB") + b = Image.open(other).convert("RGB") + if a.size != b.size: + print(name, "size", a.size, b.size) + continue + diff = ImageChops.difference(a, b) + count = sum(1 for p in diff.getdata() if p != (0, 0, 0)) + print(name, count, diff.getbbox()) diff --git a/.agents/skills/migrate-ds-area/scripts/trip.py b/.agents/skills/migrate-ds-area/scripts/trip.py new file mode 100644 index 0000000000..dde5c96bf0 --- /dev/null +++ b/.agents/skills/migrate-ds-area/scripts/trip.py @@ -0,0 +1,33 @@ +"""Before | after | red diff image, one row per snapshot. + +Usage: python3 -I trip.py +Both folders hold PNGs with the same names (crop them first if needed). +""" +import os +import sys + +from PIL import Image, ImageChops + +before, after, out, scale = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) +rows = [] +for name in sorted(os.listdir(before)): + a = Image.open(os.path.join(before, name)).convert("RGB") + b = Image.open(os.path.join(after, name)).convert("RGB") + w, h = max(a.width, b.width), max(a.height, b.height) + pad_a = Image.new("RGB", (w, h), (200, 200, 200)); pad_a.paste(a, (0, 0)) + pad_b = Image.new("RGB", (w, h), (200, 200, 200)); pad_b.paste(b, (0, 0)) + mask = ImageChops.difference(pad_a, pad_b).convert("L").point(lambda v: 255 if v else 0) + red = Image.new("RGB", (w, h), (255, 255, 255)) + red.paste((255, 0, 0), mask=mask) + row = Image.new("RGB", (w * 3 + 20, h), (200, 200, 200)) + for i, im in enumerate([pad_a, pad_b, red]): + row.paste(im, (i * (w + 10), 0)) + rows.append(row.resize((row.width * scale, row.height * scale), Image.NEAREST)) +width = max(r.width for r in rows) +height = sum(r.height + 10 for r in rows) +canvas = Image.new("RGB", (width, height), (255, 255, 255)) +y = 0 +for r in rows: + canvas.paste(r, (0, y)) + y += r.height + 10 +canvas.save(out) diff --git a/.agents/skills/refactor-scss/SKILL.md b/.agents/skills/refactor-scss/SKILL.md new file mode 100644 index 0000000000..019dface29 --- /dev/null +++ b/.agents/skills/refactor-scss/SKILL.md @@ -0,0 +1,421 @@ +--- +name: refactor-scss +description: Migrate one Penpot SCSS module off the deprecated styles under frontend/resources/styles/common/refactor/ to the current Design System (ds/ tokens, mixins and px2rem). Pure refactor, no visual change. Use when the user asks to refactor, migrate or clean up a .scss file to the DS. +--- + +# Skill: refactor-scss + +Refactor a single SCSS file so that it no longer depends on the deprecated +styling system, using the current Design System instead. + +This is a **pure refactor**. Do NOT introduce visual or behavioral changes. +Preserve the generated CSS behavior exactly unless a rule below requires +otherwise. The rules that allow a small visible change (closest DS +typography, DS border width) want it shown in the visual check and listed +in the report and the commit body. + +Reviewers read every line of the result. A line that is +copied but does nothing, a bare element selector or a raw z-index comes +back as a review comment, so the rules below are strict about them. + +## Input + +- The path to one `.scss` file (e.g. `frontend/src/app/main/ui/foo/bar.scss`). +- If no path is given, ask for it. Do not pick a file yourself. + +## Workflow + +1. Read the target `.scss` file and the `.cljs` file with the same name next + to it. +2. Read every deprecated source the file uses, so you inline real values, + never remembered ones: + - `frontend/resources/styles/common/refactor/mixins.scss` + - `frontend/resources/styles/common/refactor/design-tokens.scss` + - `frontend/resources/styles/common/refactor/spacing.scss` + - any other file under `frontend/resources/styles/common/refactor/` that + the stylesheet pulls in (placeholders, borders, z-index, fonts...). +3. Read the DS modules you will map to: + - `frontend/src/app/main/ui/ds/_utils.scss` (`px2rem()`) + - `frontend/src/app/main/ui/ds/spacing.scss` + - `frontend/src/app/main/ui/ds/_sizes.scss` + - `frontend/src/app/main/ui/ds/typography.scss` (`use-typography()`) + - `frontend/src/app/main/ui/ds/_borders.scss` + - `frontend/src/app/main/ui/ds/z-index.scss` +4. Apply the rules below and edit the file in place. +5. Verify (see **Verification** and **Visual check**). +6. Report (see **Output**). Do not commit. When the refactor goes with other + changes, it belongs in its own commit, after the task's commits. + +## Primary goal + +After the refactor, the SCSS file must not depend on anything under +`frontend/resources/styles/common/refactor/`. This includes imports, mixins, +placeholder selectors, functions, deprecated variables and design-token +aliases. + +### Partial refactor + +Some placeholders stand for a whole component family that will move to the DS +in its own PR: `%input-element`, `%input-with-label`, `%input-checkbox`, +`%modal-*`, `%menu-dropdown`, `%menu-item-base`, `%shortcut-*` and similar. +Inlining them in a small task produces big, risky diffs. In that case: + +- migrate everything else (tokens, sizes, spacing, small mixins); +- keep the `refactor/` import only for those placeholders; +- list each remaining dependency and the selectors that use it in the report, + so the user can plan it as a separate PR. + +## General rules + +- Keep the resulting SCSS as small and readable as possible. +- Avoid duplicated declarations and duplicated selectors. +- Do not change class names. +- Do not change DOM structure. +- Do not change specificity unless required. +- Follow the project's SCSS formatting (Prettier, see **Verification**). + +## Imports + +Use the DS modules. Import them with `as *` when they expose mixins, +variables or functions: + +```scss +@use "ds/_utils.scss" as *; +``` + +not + +```scss +@use "ds/_utils.scss" as utils; +``` + +Do not introduce namespaces such as `utils.`, `typography.`, `borders.`, +`spacing.` or `sizes.` unless a module explicitly requires one. + +Remove every unused import. + +## Deprecated mixins + +If the stylesheet uses mixins from +`frontend/resources/styles/common/refactor/mixins.scss`: + +- locate the mixin; +- inline its generated CSS (then apply the rest of these rules to it); +- remove the `@include`; +- remove the import. + +Do not leave deprecated mixins. + +## Placeholders + +Replace every `@extend %placeholder` with the generated CSS. Remove every +deprecated placeholder, except the component-family ones described in +**Partial refactor**. + +Inline only what does something on that element. Do not paste a +placeholder (or mixin) body as it is. For each property, check: + +- **It applies to the element.** `fill`, `stroke` and `stroke-width` only + act on SVG shapes: drop them from a `span`, `div` or `button`. + `color: transparent` on a wrapper that only holds an icon does nothing + useful: drop it and set the real color. +- **It is not the default or already given.** `stroke-width: 1px` is the + SVG default; `fill: none` and `stroke: currentColor` are already set by + the DS `icon*` (`.icon`). Drop them. +- **A later rule does not override it.** +- **It is not a stroke width on a DS icon.** DS icons keep the DS + stroke width; drop any `stroke-width` that only imitates the legacy + icon (1.33px, 2.25px...). + +`%button-icon` is the usual case: on a wrapper it leaves `color: +transparent; fill: none; stroke-width: 1px`, and none of the three is +needed. + +## Element selectors + +Do not style HTML elements directly (`svg {}`, `h3 {}`, `button {}`, +`input.x {}`, or `.component { svg {} }`). + +When no class exists, **add one in the `.cljs`**: a DS component takes +`:class (stl/css :x)` (`icon*`, `heading*`, `button*`…), and a plain +element takes `{:class (stl/css :x)}`. Touching the `.cljs` for this is +part of the refactor. A comment like "the icon has no class" is not an +answer. + +Prefer the DS component when one fits: `heading*` for `h1`–`h6` +(`:level`, `:typography`), `text*` for text, `icon*` for icons. + +`element.class` (e.g. `input.element-title`) is not needed to beat the +input defaults: try the class alone and check it in the visual check. + +An element selector stays only for markup you cannot reach (injected +HTML, third-party widgets), with a comment saying so. + +**Icon colors.** A DS icon draws with `stroke: currentColor`. Set +`color` on the icon class or its parent, never `stroke`/`fill` on an +`svg` selector. + +## Color variables + +Whenever a color changes across states, introduce a component-local CSS +variable. + +Instead of + +```scss +.button { + color: var(--color-foreground-primary); + + &:hover { + color: var(--color-accent-primary); + } + + &:disabled { + color: var(--color-foreground-disabled); + } +} +``` + +write + +```scss +.button { + --button-foreground-color: var(--color-foreground-primary); + + color: var(--button-foreground-color); + + &:hover { + --button-foreground-color: var(--color-accent-primary); + } + + &:disabled { + --button-foreground-color: var(--color-foreground-disabled); + } +} +``` + +Use this pattern for `color`, `background-color`, `border-color`, +`outline-color`, `fill`, `stroke` and any similar property. + +## Design token aliases + +Remove every alias defined in +`frontend/resources/styles/common/refactor/design-tokens.scss`. Resolve each +alias to its final DS token. Do not keep intermediate aliases. + +Instead of `color: var(--button-foreground-hover);` write +`color: var(--color-accent-primary);`. + +## Spacing + +Replace deprecated spacing variables using +`frontend/src/app/main/ui/ds/spacing.scss` for `margin`, `padding`, `gap` and +`inset`. + +If no exact spacing token exists, use `px2rem()`. Do not approximate with the +closest token. + +## Sizes + +Replace deprecated size variables using +`frontend/src/app/main/ui/ds/_sizes.scss` for `inline-size`, `block-size`, +`width`, `height` and min/max sizes. + +If no exact size exists, use `px2rem()` from +`frontend/src/app/main/ui/ds/_utils.scss`. Do not leave hardcoded pixel values +unless absolutely necessary. + +## Typography + +When any of `font-size`, `font-weight`, `line-height`, `font-family` or +`letter-spacing` appear, check whether together they match one of the +definitions in `frontend/src/app/main/ui/ds/typography.scss`. + +If they match, replace all the individual declarations with: + +```scss +@include use-typography(""); +``` + +If there is no exact match, use the **closest DS typography** (same role: +a panel title in uppercase → `headline-small`, body text → `body-*`), +even when more than one detail differs. Show the change in the visual +check and state it in the report and in the commit body (e.g. "11px +regular → 12px medium"). + +Keep the original declarations only when no DS typography fits the role +(e.g. a 32px display number in a 14px-only area); then leave a TODO +naming the closest one and ask the user. + +## Borders + +Replace deprecated border radius and border width values using +`frontend/src/app/main/ui/ds/_borders.scss`. A raw width close to a token +(`1.75px`) takes the token (`$b-2`); say so in the report. If nothing is +close, use `px2rem()`. + +## Logical properties + +Prefer logical properties whenever they preserve the existing behavior: + +- `width` → `inline-size` +- `height` → `block-size` +- `max-width` → `max-inline-size` +- `margin-left` → `margin-inline-start` +- `padding-right` → `padding-inline-end` +- `top` → `inset-block-start` +- `left` → `inset-inline-start` + +## Z-index + +Never leave magic z-index values. Use tokens from +`frontend/src/app/main/ui/ds/z-index.scss`, and pick the token **by what +the element is, not by its number**: a dropdown, menu or select → +`--z-index-dropdown`; a toolbar, palette, sidebar or panel → +`--z-index-panels`; and so on (read the comments in that file). A raw `4` +on a dropdown becomes `var(--z-index-dropdown)` even though the number +changes. Check the visual check for overlap. + +Leave a TODO only when the element fits no token's purpose. + +Legacy z-index values between sibling elements that do not overlap can be +removed, but only when the **Visual check** shows no pixel change in every +state that matters (open menus, collapsed, hidden). Say so in the report. + +## Duplicated properties + +Within the same selector, avoid duplicated properties, duplicated CSS +variables and duplicated declarations introduced by the refactor. Allow +duplicates only when they are intentional browser fallbacks. + +## Unused classes + +Open the `.cljs` file with the same name as the SCSS file and check whether +every CSS class is referenced (as `(stl/css :class-name)`, +`(stl/css-case :class-name ...)` or similar). + +CSS modules prefix every class with its module +(`main_ui___`), so only the `.cljs` of the same module can use +it. Before calling a selector unused, also search the whole `frontend/src` +for the class name as a string, a `:global` use, or a dynamic class built +at runtime. + +If a selector is unused after that check: + +- delete it (do not leave it commented out, do not add a TODO); +- list every deleted selector in the report and in the commit body. + +A rule that matches nothing because the markup changed (e.g. `.icon svg` +when the DS icon is the `svg` itself) is deleted the same way. Before +deleting a descendant rule, check every element it matched, not only +the one you changed: `.placeholder .icon svg` can also color a second +icon, which then has no color (in the devenv it shows in the debug +yellow). + +Every DS icon needs a color of its own (`color` on its class or on a +parent with a defined color): an icon that inherits nothing shows in +the debug yellow in the devenv. + +Classes used by markup injected from outside (e.g. an external widget) +stay, with a comment saying so. + +Also check the other direction: every class the `.cljs` passes to +`stl/css` / `stl/css-case` must exist in the `.scss`. A missing one silently +does nothing; report it. + +## Sass and CSS pitfalls + +- Sass does not evaluate functions inside CSS custom properties. Write + `--button-height: calc(var(--height) - #{px2rem(16)});`, not + `... - px2rem(16))`, or the browser gets the literal `px2rem(16)`. +- A `/` between Sass size variables is read as a division + (`grid-template: $sz-32 / auto` breaks). Split it into + `grid-template-rows` / `grid-template-columns` (or areas). +- A CSS-wide keyword cannot be part of a shorthand: `inset-inline: unset 0` + is invalid and stylelint does not catch it. Use the longhands. +- After editing, check the compiled CSS for any of these + (`frontend/resources/public/css/main.css`, search the module class). + +## Uncertainty + +If any transformation is uncertain, never guess. Leave a TODO in the code +and explain why in the report. + +## Self-review + +Before the report, read the final diff as the reviewer would and search +the file for these; each hit must be fixed or have a real reason: + +```bash +rg -n 'svg \{|^\s*(h[1-6]|p|hr|input|button|span|a)\b[^-_]|[a-z]\.[a-z-]+ \{' +rg -n 'stroke-width|color: transparent|fill: none|z-index: [0-9]|TODO' +rg -n '[0-9]\.[0-9]+px|[^(]\b[0-9]+px' +``` + +- an element selector → add a class (see **Element selectors**); +- a property that does not apply or is a default → drop it (see + **Placeholders**); +- a raw z-index → token by purpose; +- a typography TODO → closest DS typography; +- a raw px value → token or `px2rem()`. + +## Verification + +Run the project scripts from `frontend/` inside the devenv container +(`penpot-devenv-ws-main` for workspace `wsN`; never run `pnpm` on the +host), writing output to a file first (never pipe lint output into +filters): + +```bash +docker exec -u penpot -w /home/penpot/penpot/frontend penpot-devenv-ws-main \ + bash -lc 'pnpm run lint:scss; echo LINT=$?; pnpm run check-fmt:scss; echo FMT=$?' \ + > /scss-check.txt 2>&1 +``` + +If the format check fails, run `pnpm run fmt:scss` and check again. + +**Dependency check.** A grep for `refactor/` is not enough. For the final +file, check: + +- `@use` / `@import` sources: all must be `ds/...` (or the sidebar common + module, which is DS-only); +- every `@include`, `@extend` and `%placeholder`; +- every `$variable`: which imported module defines it (`$br-*` exists in both + `ds/_borders.scss` and `refactor/borders.scss`; the `@use` decides); +- every `var(--token)`: defined in the DS (colors, spacing, sizes) or only as a + `refactor/` alias. + +Report any failure as it is; do not hide it. + +## Visual check + +"No visual change" must be shown, not assumed. When a visual spec covers the +component (`frontend/playwright/ui/visual-specs/`, project `ds`), or one can be +added: + +1. Capture before (base branch styles) and after with + `pnpm exec playwright test --project=ds -g "" --update-snapshots=all` + in the container. `=all` is required: plain `--update-snapshots` only + rewrites past the 0.5% tolerance, so a real diff can look like 0. +2. Before each capture, wait until the compiled CSS/JS shows the expected + version (search `main.css` for the changed rule). +3. Compare with zero tolerance. If pixels change, build the before | after | + diff (red) image and explain every change in the report. + +## Output + +1. The diff of the file (`git diff -- `). +2. A summary with: + - removed imports; + - removed deprecated mixins; + - removed placeholders; + - typography replacements; + - design-token aliases removed; + - logical properties introduced; + - deleted unused selectors; + - TODOs requiring manual review, each with its reason; + - remaining `refactor/` dependencies (partial refactor) and where they are + used; + - classes missing from the `.scss`, if any. +3. The verification results, including the visual check (pixel count, or why + it was not possible). From 4be8df58778e056db8291006fa6ab2817923660b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 4 Nov 2026 15:50:13 +0100 Subject: [PATCH 230/325] :zap: Load the rasterizer iframe only when the SVG renderer needs it (#12835) Create it once features say SVG, never in wasm mode. Also skip SVG frame thumbnails in wasm mode. --- frontend/src/app/main.cljs | 12 ++-- .../app/main/data/workspace/libraries.cljs | 9 +-- frontend/src/app/main/rasterizer.cljs | 69 ++++++++++--------- 3 files changed, 49 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 635572996f..047807464c 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -67,9 +67,10 @@ [] (ptk/reify ::initialize-rasterizer ptk/EffectEvent - (effect [_ _ _] - ;; The rasterizer is used for the dashboard thumbnails - (thr/init!)))) + (effect [_ state _] + ;; Only the SVG renderer uses the rasterizer + (when-not (feat/active-feature? state "render-wasm/v1") + (thr/init!))))) (defn initialize [] @@ -107,9 +108,8 @@ (rx/map #(ws/initialize))) (->> stream - (rx/filter (ptk/type? ::feat/initialize)) - (rx/take 1) - (rx/map #(initialize-rasterizer))))))) + (rx/filter (comp #{::feat/initialize ::feat/recompute-features} ptk/type)) + (rx/map initialize-rasterizer)))))) (defn ^:export init [options] diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index bc52e63736..8ebcfacff7 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -961,11 +961,12 @@ component (ctkl/get-component data component-id) page-id (:main-instance-page component) root-id (:main-instance-id component)] - (if (and (= tag "component") - (features/active-feature? state "render-wasm/v1")) + (if (features/active-feature? state "render-wasm/v1") ;; WASM: render immediately, UI only — server persist happens - ;; on the debounced path (update-component-thumbnail) - (dwt.wasm/render-thumbnail file-id page-id root-id) + ;; on the debounced path (update-component-thumbnail). Frame + ;; thumbnails are only shown by the SVG viewport. + (when (= tag "component") + (dwt.wasm/render-thumbnail file-id page-id root-id)) (dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync")))) (defn- render-component-thumbnail-event diff --git a/frontend/src/app/main/rasterizer.cljs b/frontend/src/app/main/rasterizer.cljs index 9e32471c38..468fe60c82 100644 --- a/frontend/src/app/main/rasterizer.cljs +++ b/frontend/src/app/main/rasterizer.cljs @@ -25,6 +25,7 @@ (defonce ready? false) (defonce queue #js []) (defonce instance nil) +(defonce started? false) (defonce msgbus (rx/subject)) (defonce origin (-> cf/rasterizer-uri @@ -32,6 +33,7 @@ (dm/str))) (declare send-message!) +(declare init!) (defn- process-queued-messages! [] @@ -86,6 +88,8 @@ :scope "penpot/rasterizer" :payload payload}] + (init!) + (if ^boolean ready? (send-message! message) (queue-message! message)) @@ -108,39 +112,42 @@ (render {:data data :styles styles :width width :result result}))) (defn init! - "Initializes the rasterizer." + "Creates the rasterizer iframe once. It is only needed by the SVG + renderer, so sessions with render-wasm never load it." [] - (let [iframe (dom/create-element "iframe")] - (dom/set-attribute! iframe "id" "rasterizer") - (dom/set-attribute! iframe "src" origin) - (dom/set-attribute! iframe "hidden" true) - (.addEventListener js/window "message" on-message) - (->> (http/fetch {:method :head - :uri cf/rasterizer-uri - :mode :no-cors}) - (rx/map (fn [response] - (let [allowed? (not (.-redirected response))] - (when-not allowed? - (log/err :hint "rasterizer iframe blocked by adblocker" :origin origin)) - allowed?))) - (rx/catch (fn [cause] - (log/err :hint "rasterizer iframe blocked by adblocker" :origin origin :cause cause) - (rx/of false))) + (when-not ^boolean started? + (set! started? true) + (let [iframe (dom/create-element "iframe")] + (dom/set-attribute! iframe "id" "rasterizer") + (dom/set-attribute! iframe "src" origin) + (dom/set-attribute! iframe "hidden" true) + (.addEventListener js/window "message" on-message) + (->> (http/fetch {:method :head + :uri cf/rasterizer-uri + :mode :no-cors}) + (rx/map (fn [response] + (let [allowed? (not (.-redirected response))] + (when-not allowed? + (log/err :hint "rasterizer iframe blocked by adblocker" :origin origin)) + allowed?))) + (rx/catch (fn [cause] + (log/err :hint "rasterizer iframe blocked by adblocker" :origin origin :cause cause) + (rx/of false))) - (rx/subs! (fn [allowed?] - (if allowed? - (do - (dom/append-child! js/document.body iframe) - (set! instance iframe)) + (rx/subs! (fn [allowed?] + (if allowed? + (do + (dom/append-child! js/document.body iframe) + (set! instance iframe)) - (let [new-origin (-> cf/public-uri - (u/join "rasterizer.html") - (dm/str))] - (log/warn :hint "fallback to main domain" :origin new-origin) + (let [new-origin (-> cf/public-uri + (u/join "rasterizer.html") + (dm/str))] + (log/warn :hint "fallback to main domain" :origin new-origin) - (dom/set-attribute! iframe "src" new-origin) - (dom/append-child! js/document.body iframe) + (dom/set-attribute! iframe "src" new-origin) + (dom/append-child! js/document.body iframe) - (set! origin new-origin) - (set! cf/rasterizer-uri cf/public-uri) - (set! instance iframe)))))))) + (set! origin new-origin) + (set! cf/rasterizer-uri cf/public-uri) + (set! instance iframe))))))))) From d42217a32be817b22d28f7e5ba14e87983ad9e74 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Thu, 5 Nov 2026 01:40:14 +0900 Subject: [PATCH 231/325] :bug: Create Plugin API shapes at the page root (#12499) * :bug: Create Plugin API shapes at the page root Create component instances and SVG shapes at the page root so a board at the page origin cannot capture them. Continue using the existing forced frame option for component instances and an explicit parent frame for SVG imports. Interactive placement retains its current behavior. Cover component instances, SVG imports and SVG imports with images on ordinary, flex and grid origin boards, including undo, redo, position changes and explicit reparenting. Closes #12086 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: choi junsoo * :bug: Preserve interactive creation placement Cover component and SVG placement inside and outside an origin board. Exercise the sidebar move lifecycle and verify parent preservation. Correct the Plugin API changelog category. AI-assisted-by: gpt-6 Signed-off-by: choi junsoo * :wrench: Cover page-root creation in the Plugin API test suite Check that LibraryComponent.instance(), createShapeFromSvg() and createShapeFromSvgWithImages() create shapes at the page root when a board covers the page origin. Each test removes the shape it creates, since it lives outside the scratch board. AI-assisted-by: claude-opus-5-5 --------- Signed-off-by: choi junsoo Co-authored-by: alonso.torres --- .../app/main/data/workspace/libraries.cljs | 8 +- .../src/app/main/data/workspace/media.cljs | 6 +- .../app/main/data/workspace/svg_upload.cljs | 11 +- frontend/src/app/plugins/library.cljs | 2 +- .../plugins/creation_parent_test.cljs | 156 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + plugins/CHANGELOG.md | 1 + .../src/tests/components.test.ts | 14 ++ .../src/tests/shapes-factories.test.ts | 37 +++++ 9 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 frontend/test/frontend_tests/plugins/creation_parent_test.cljs diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 8ebcfacff7..56ad36cbc6 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -614,7 +614,7 @@ in the given file library. Then selects the newly created instance." ([file-id component-id position] (instantiate-component file-id component-id position nil)) - ([file-id component-id position {:keys [start-move? initial-point id-ref origin]}] + ([file-id component-id position {:keys [start-move? initial-point id-ref origin frame-id]}] (dm/assert! (uuid? file-id)) (dm/assert! (uuid? component-id)) (dm/assert! (gpt/point? position)) @@ -637,7 +637,11 @@ component-id position page - libraries) + libraries + nil + frame-id + frame-id + {:force-frame? (some? frame-id)}) component (ctn/get-component-from-shape new-shape libraries) undo-id (js/Symbol)] diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index f3a395a504..979b84bc4d 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -487,7 +487,8 @@ (watch [_ _ _] (->> (svg->clj [name svg-string]) (rx/take 1) - (rx/map #(svg/add-svg-shapes id % position {:ignore-selection? true + (rx/map #(svg/add-svg-shapes id % position {:frame-id uuid/zero + :ignore-selection? true :change-selection? false})))))) (defn create-svg-shape-with-images [file-id id name svg-string position on-success on-error] @@ -506,7 +507,8 @@ id svg-data position - {:ignore-selection? true + {:frame-id uuid/zero + :ignore-selection? true :change-selection? false}))) (rx/tap on-success) (rx/catch on-error))))) diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index a972edb3d9..48a84f0b30 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -70,7 +70,7 @@ ([svg-data position] (add-svg-shapes nil svg-data position nil)) - ([id svg-data position {:keys [change-selection? ignore-selection?] + ([id svg-data position {:keys [change-selection? ignore-selection? frame-id] :or {ignore-selection? false change-selection? true}}] (ptk/reify ::add-svg-shapes ptk/WatchEvent @@ -88,10 +88,11 @@ base-id (:parent-id base) - frame-id (if (or selected-frame? (empty? selected) - (not= :frame (dm/get-in objects [base-id :type]))) - (ctst/top-nested-frame objects position) - base-id) + frame-id (or frame-id + (if (or selected-frame? (empty? selected) + (not= :frame (dm/get-in objects [base-id :type]))) + (ctst/top-nested-frame objects position) + base-id)) parent-id (if (or selected-frame? (empty? selected) (nil? base)) frame-id diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index be94b45249..e7a91f9236 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -936,7 +936,7 @@ :else (let [id-ref (atom nil)] - (st/emit! (-> (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin"}) + (st/emit! (-> (dwl/instantiate-component file-id id (gpt/point 0 0) {:id-ref id-ref :origin "plugin" :frame-id uuid/zero}) (se/add-event plugin-id))) (shape/shape-proxy plugin-id @id-ref)))) diff --git a/frontend/test/frontend_tests/plugins/creation_parent_test.cljs b/frontend/test/frontend_tests/plugins/creation_parent_test.cljs new file mode 100644 index 0000000000..76065ee734 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/creation_parent_test.cljs @@ -0,0 +1,156 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.plugins.creation-parent-test + (:require + ["sax$default" :as sax] + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.test-helpers.components :as cthc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as cths] + [app.common.uuid :as uuid] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.svg-upload :as dwsvg] + [app.main.data.workspace.undo :as dwu] + [app.main.refs :as refs] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.streams :as ms] + [app.main.worker :as mw] + [app.plugins.api :as api] + [app.plugins.library :as library] + [app.util.dom :as dom] + [app.util.object :as obj] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.state :as ths] + [frontend-tests.tokens.helpers.state :as tohs] + [potok.v2.core :as ptk] + [tubax.core :as tubax])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(def ^:private svg + "") + +(t/deftest ^:async plugin-creation-uses-the-page-root + (doseq [layout [nil :flex :grid] + creation [:component :svg :svg-with-images]] + (let [file (-> (cthf/sample-file :file :page-label :page) + (cths/add-sample-shape :main :type :frame :x 1000 :y 1000) + (cths/add-sample-shape :child :parent-label :main) + (cthc/make-component :component :main) + (cths/add-sample-shape :board :type :frame :x 0 :y 0 + :width 400 :height 300 :layout layout + :layout-grid-rows [] :layout-grid-columns [] + :layout-grid-cells {})) + store (ths/setup-store file {:renderer :svg}) + uploads (atom []) + path [:files (:id file) :data :pages-index (thi/id :page) :objects]] + (await + (mock/with-mocks* + {st/state store + st/emit! (fn + ([event] (ptk/emit! store event)) + ([event & events] (apply ptk/emit! store event events))) + mw/ask! (mock/stub (fn [_] (rx/empty))) + rp/cmd! (mock/stub (fn [cmd params] + (swap! uploads conj [cmd params]) + (rx/observe-on + :async + (rx/of {:id (uuid/next) :width 10 :height 20 + :mtype "image/png" :name "image"})))) + ;; Node exposes sax's CommonJS parser on the default export. + tubax/create-parser (mock/stub (fn [_] (sax/parser true #js {})))} + (ptk/emit! store (tohs/watch-undo-stack)) + (let [^js context (api/create-context plugin-id) + ^js created (case creation + :component (.instance (library/lib-component-proxy plugin-id (:id file) (thi/id :component))) + :svg (.createShapeFromSvg context svg) + :svg-with-images + (await (.createShapeFromSvgWithImages + context + ""))) + id (obj/get created "$id")] + (await (async/wait-for #(some? (get-in @store (conj path id))) "created shape committed")) + (let [created-shape (get-in @store (conj path id))] + (t/is (= uuid/zero (:parent-id created-shape)) (str creation " / " layout)) + (t/is (= uuid/zero (:frame-id created-shape)) (str creation " / " layout)) + (t/is (not (some #{id} (get-in @store (into path [(thi/id :board) :shapes]))))) + (t/is (= 1 (count (get-in @store [:workspace-undo :items]))))) + (when (= creation :svg-with-images) + (t/is (= [:create-file-media-object-from-url] (mapv first @uploads)))) + (ptk/emit! store dwu/undo) + (await (async/wait-for #(nil? (get-in @store (conj path id))) "creation undone")) + (t/is (nil? (get-in @store (conj path id)))) + (t/is (empty? (get-in @store (into path [(thi/id :board) :shapes])))) + (ptk/emit! store dwu/redo) + (await (async/wait-for #(some? (get-in @store (conj path id))) "creation redone")) + (set! (.-x created) 1000) + (set! (.-y created) 1200) + (await (async/wait-for #(= [1000 1200] ((juxt :x :y) (get-in @store (conj path id)))) "position updated")) + (t/is (= [1000 1200] ((juxt :x :y) (get-in @store (conj path id))))) + (let [^js page (.-currentPage context) + ^js board (.getShapeById page (str (thi/id :board)))] + (.appendChild board created)) + (await (async/wait-for #(= (thi/id :board) (get-in @store (into path [id :parent-id]))) "explicit parent applied")) + (t/is (= (thi/id :board) (get-in @store (into path [id :parent-id])))))))))) + +(t/deftest ^:async interactive-creation-keeps-coordinate-based-parents + (doseq [[creation position parent-label] [[:component (gpt/point 50 60) :board] + [:component (gpt/point 600 700) nil] + [:svg (gpt/point 50 60) :board] + [:svg (gpt/point 600 700) nil]]] + (let [file (-> (cthf/sample-file :file :page-label :page) + (cths/add-sample-shape :main :type :frame :x 1000 :y 1000) + (cths/add-sample-shape :child :parent-label :main) + (cthc/make-component :component :main) + (cths/add-sample-shape :board :type :frame :x 0 :y 0 + :width 400 :height 300)) + store (ths/setup-store file {:renderer :svg}) + id-ref (atom nil) + svg-id (uuid/next) + parent (if parent-label (thi/id parent-label) uuid/zero) + path [:files (:id file) :data :pages-index (thi/id :page) :objects]] + (await + (mock/with-mocks* + {st/state store + st/emit! (fn + ([event] (ptk/emit! store event)) + ([event & events] (apply ptk/emit! store event events))) + mw/ask! (mock/stub (fn [_] (rx/empty))) + dom/get-element-by-class (mock/stub (constantly nil)) + refs/vbox (atom (grc/make-rect 0 0 2000 2000)) + ms/mouse-position (rx/behavior-subject position) + ms/mouse-position-alt (rx/behavior-subject false) + ms/mouse-position-shift (rx/behavior-subject false) + ms/mouse-position-mod (rx/behavior-subject false)} + (let [events {:component (dwl/instantiate-component (:id file) (thi/id :component) position + {:id-ref id-ref :origin "sidebar" + :start-move? true :initial-point position}) + :svg (dwsvg/add-svg-shapes svg-id + {:name "test.svg" :attrs {:width 24 :height 24} + :content [{:tag :rect :attrs {:width "24" :height "24"}}]} + position nil)}] + (ptk/emit! store (get events creation))) + (let [id (if (= creation :component) @id-ref svg-id)] + (await (async/wait-for #(some? (get-in @store (conj path id))) "interactive shape committed")) + (t/is (= parent (get-in @store (into path [id :parent-id])))) + (t/is (= parent (get-in @store (into path [id :frame-id])))) + (t/is (= #{id} (set (get-in @store [:workspace-local :selected])))) + (when (= creation :component) + (await (async/wait-for #(= :move (get-in @store [:workspace-local :transform])) "interactive move started")) + (t/is (= :move (get-in @store [:workspace-local :transform]))) + (rx/push! ms/mouse-position position) + (await (async/settle)) + (ptk/emit! store :interrupt) + (await (async/wait-for #(nil? (get-in @store [:workspace-local :transform])) "interactive move finished")) + (t/is (nil? (get-in @store [:workspace-local :transform]))) + (t/is (= parent (get-in @store (into path [id :parent-id]))))))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index c0fee67ec6..0ddc7429cb 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -72,6 +72,7 @@ [frontend-tests.main.refs-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.creation-parent-test] [frontend-tests.plugins.events-test] [frontend-tests.plugins.file-test] [frontend-tests.plugins.flex-test] @@ -253,6 +254,7 @@ 'frontend-tests.logic.wasm-pixel-snap-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.creation-parent-test 'frontend-tests.plugins.events-test 'frontend-tests.plugins.management-test 'frontend-tests.plugins.file-test diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 08b8e2d58e..7b667790fc 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -14,6 +14,7 @@ ### 🩹 Fixes +- **plugin-runtime:** `LibraryComponent.instance()`, `createShapeFromSvg()` and `createShapeFromSvgWithImages()` now create shapes at the page root instead of inside a board at the page origin. Closes #12086. - **plugin-types, plugin-runtime:** Dashboard property setters now update values after backend success and log failures without interrupting the workspace. File moves reject projects in other teams, backend rejections include their type and code, and `project.fileCount` stays at its listing snapshot. Duplicated projects include their file count and pinned state. - **plugin-runtime:** Cancel plugin loads before they execute after unloading or logout, and allow reopening plugins that close during startup. - **plugins-runtime**: `library.connectLibrary()` now resolves once the library's assets are loaded; it used to resolve with an empty library the first time. `library.connected` and `library.availableLibraries()` ignore files opened earlier in the same tab, which used to show up as connected and be left out of the available libraries. diff --git a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts index 92d68fd6aa..b2c16486f4 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts @@ -64,6 +64,20 @@ describe('Component instances', () => { expect(inst.isVariantHead()).toBeFalsy(); }); + // instance() places the copy at the page origin; a board covering that point + // must not capture it. + test('instance creates the copy at the page root', (ctx) => { + ctx.board.x = 0; + ctx.board.y = 0; + const inst = makeComponent(ctx).instance(); + try { + expect(inst.parent?.id).toBe(ctx.penpot.root?.id); + expect(ctx.board.children.map((c) => c.id)).not.toContain(inst.id); + } finally { + inst.remove(); + } + }); + test('component navigation methods return shapes', (ctx) => { const inst = instanceOf(ctx); expect(inst.componentRoot()).toBeDefined(); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts index 6dc8be8fa5..eb0782cae0 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-factories.test.ts @@ -325,4 +325,41 @@ describe('Shapes', () => { expect(a.x).toBeCloseTo(10, 0); }); }); + + // The factories place new shapes at the page origin. A board covering that + // point must not capture them: they belong to the page root until the plugin + // reparents them. + describe('Placement', () => { + const svg = + '' + + ''; + + test('createShapeFromSvg creates the shape at the page root', (ctx) => { + ctx.board.x = 0; + ctx.board.y = 0; + const group = ctx.penpot.createShapeFromSvg(svg); + expect(group).not.toBeNull(); + if (!group) return; + try { + expect(group.parent?.id).toBe(ctx.penpot.root?.id); + expect(ctx.board.children.map((c) => c.id)).not.toContain(group.id); + } finally { + group.remove(); + } + }); + + test('createShapeFromSvgWithImages creates the shape at the page root', async (ctx) => { + ctx.board.x = 0; + ctx.board.y = 0; + const group = await ctx.penpot.createShapeFromSvgWithImages(svg); + expect(group).not.toBeNull(); + if (!group) return; + try { + expect(group.parent?.id).toBe(ctx.penpot.root?.id); + expect(ctx.board.children.map((c) => c.id)).not.toContain(group.id); + } finally { + group.remove(); + } + }); + }); }); From eb9594a614b986b3df73b202f9a2dcd94511c5cd Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 4 Nov 2026 21:47:55 +0100 Subject: [PATCH 232/325] :bug: Fix ChatGPT tool discovery with the submit_feedback tool (#12845) * :bug: Fix ChatGPT tool discovery with the submit_feedback tool ChatGPT compiles the regex patterns advertised in tool input schemas with Python, which rejects Unicode property escapes such as \P{Cc}. The submit_feedback subject pattern used one, so ChatGPT dropped the whole tool list ("action discovery failed") on deployments that register the tool. The subject pattern now spells out the Cc range as a character class, which accepts the same input. A test asserts that no advertised pattern uses \p{...} or \P{...}. AI-assisted-by: claude-opus-5-5 * :bug: Fix MCP plugin test stub for the version check plugin.ts checks version compatibility with extractMinorVersion, but the tests loaded it with a require stub that returned the same dummy object for every import, so the check threw once the UI initialized. The plugin.ts tests now share a stub that supplies the real VersionCompatibility, ErrorUtils and TaskHandler modules, stubs only the code execution handler and rejects unexpected imports. TaskHandler is transpiled for the sandbox because Node's type stripping cannot load its parameter properties. AI-assisted-by: claude-opus-5-5 * :bug: Fix Management tests for backend-confirmed setters Management setters save to the backend first and update the object only when the save succeeds, so seven live tests failed: - setter tests now wait for the new value before reading it - createProject checks fileCount by listing the project again, as fileCount is the count from when the project was listed - project moveTo no longer calls remove on the target team, which teams do not have; the live CI driver deletes temporary teams - renaming the open file waits for its restore, which made the next navigation test fail The README notes on setters, fileCount and team cleanup match this. AI-assisted-by: claude-opus-5-5 --- .../plugin/src/ConnectionMetadata.test.ts | 24 ++++++++++-- mcp/packages/server/src/Feedback.test.ts | 19 ++++++++++ .../server/src/tools/SubmitFeedbackTool.ts | 4 +- plugins/apps/plugin-api-test-suite/README.md | 18 +++++---- .../src/tests/management.test.ts | 37 ++++++++++++------- 5 files changed, 77 insertions(+), 25 deletions(-) diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts index 1ae436afd6..f0f26f405d 100644 --- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts +++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts @@ -4,9 +4,11 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { runInNewContext } from "node:vm"; import ts from "typescript"; +import * as ErrorUtils from "./ErrorUtils.ts"; import { shouldReconnectAfterClose } from "./ReconnectPolicy.ts"; import { SessionId } from "./SessionId.ts"; import { SessionIdDisplay } from "./SessionIdDisplay.ts"; +import * as VersionCompatibility from "./VersionCompatibility.ts"; /** Compiles the actual plugin entry point for a simulated browser boundary. */ function compile(name: string) { @@ -15,6 +17,22 @@ function compile(name: string) { }).outputText; } +/** Exports of a compiled module that Node's type stripping cannot load directly. */ +function compiledModule(name: string) { + const exports = {}; + runInNewContext(compile(name), { exports }); + return exports; +} + +/** Resolves the imports of `plugin.ts`, stubbing only the code execution handler. */ +function pluginRequire(name: string) { + if (name === "./task-handlers/ExecuteCodeTaskHandler") return { ExecuteCodeTaskHandler: class {} }; + if (name === "./TaskHandler") return compiledModule("TaskHandler.ts"); + if (name === "./ErrorUtils") return ErrorUtils; + if (name === "./VersionCompatibility") return VersionCompatibility; + throw new Error(`Unexpected import: ${name}`); +} + function pluginUi() { const messages: any[] = []; const sockets: FakeSocket[] = []; @@ -184,7 +202,7 @@ test("plugin reads the current file for each connection metadata request", () => on() {}, }, mcp: undefined, - require: () => ({ ExecuteCodeTaskHandler: class {} }), + require: pluginRequire, }); onMessage({ type: "connection-metadata-request", sessionId: "first" }); penpot.currentFile = { id: "file-2", name: "Second design" }; @@ -223,7 +241,7 @@ test("plugin without penpotMgmt derives metadata from the open file", () => { exports: {}, penpot, mcp: undefined, - require: () => ({ ExecuteCodeTaskHandler: class {} }), + require: pluginRequire, }); onMessage({ type: "connection-metadata-request", sessionId: "first" }); penpot.currentFile = null; @@ -313,7 +331,7 @@ test("integrated plugin stays idle until explicitly connected, including while t setMcpStatus: (status: string) => statuses.push(status), on: (event: string, callback: () => void) => events.set(event, callback), }, - require: () => ({ ExecuteCodeTaskHandler: class {} }), + require: pluginRequire, }); assert.ok(!statuses.includes("connecting")); events.get("connect")!(); diff --git a/mcp/packages/server/src/Feedback.test.ts b/mcp/packages/server/src/Feedback.test.ts index b5b6460250..be92aac8ea 100644 --- a/mcp/packages/server/src/Feedback.test.ts +++ b/mcp/packages/server/src/Feedback.test.ts @@ -70,6 +70,21 @@ async function connect(multiUser: boolean, token?: string) { const feedback = { subject: "Export fails", feedback: "Expected a PNG, received an error." }; +/** + * Collects all `pattern` values in a JSON schema. + */ +function schemaPatterns(schema: unknown): string[] { + if (Array.isArray(schema)) { + return schema.flatMap(schemaPatterns); + } + if (schema === null || typeof schema !== "object") { + return []; + } + return Object.entries(schema).flatMap(([key, value]) => + key === "pattern" && typeof value === "string" ? [value] : schemaPatterns(value) + ); +} + for (const multiUser of [false, true]) { test(`feedback is absent without a backend URI (multi-user=${multiUser})`, async () => { const connected = await connect(multiUser, "alice"); @@ -82,6 +97,10 @@ for (const multiUser of [false, true]) { const tool = (await connected.listTools()).tools.find((tool) => tool.name === "submit_feedback"); assert.ok(tool); assert.deepEqual(tool.inputSchema.required, ["subject", "feedback"]); + // advertised patterns must also compile outside ECMAScript (ChatGPT uses Python) + const patterns = schemaPatterns(tool.inputSchema); + assert.ok(patterns.length > 0); + assert.ok(!patterns.some((pattern) => /\\[pP]\{/.test(pattern)), patterns.join(", ")); const result = await connected.callTool({ name: "submit_feedback", arguments: feedback }); assert.deepEqual(result.content, [ { type: "text", text: "Feedback queued. It is emailed in the background, so delivery is not confirmed." }, diff --git a/mcp/packages/server/src/tools/SubmitFeedbackTool.ts b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts index 4c7b92f952..57273991fb 100644 --- a/mcp/packages/server/src/tools/SubmitFeedbackTool.ts +++ b/mcp/packages/server/src/tools/SubmitFeedbackTool.ts @@ -12,7 +12,9 @@ export class SubmitFeedbackArgs implements Feedback { .min(1) .max(120) .regex(/\S/) - .regex(/^\P{Cc}*$/u) + // the Unicode Cc category; `\P{Cc}` is avoided because clients that compile + // advertised patterns with other regex engines (e.g. Python) reject it + .regex(/^[^\x00-\x1F\x7F-\x9F]*$/) .describe("Short, specific summary without line breaks or other control characters (max 120 characters)."), feedback: z .string() diff --git a/plugins/apps/plugin-api-test-suite/README.md b/plugins/apps/plugin-api-test-suite/README.md index a04657cfff..2504756974 100644 --- a/plugins/apps/plugin-api-test-suite/README.md +++ b/plugins/apps/plugin-api-test-suite/README.md @@ -247,13 +247,17 @@ with `name`/`pinned`/`shared` properties and `open`, `moveTo`, `duplicate` and `remove` methods. `management.test.ts` shows the patterns: - Put every team and project a test creates under a name from - `temporaryName()` (`src/framework/temporary.ts`). Tests delete the teams they - create; the live CI driver deletes the teams and projects with that prefix - after the run. Runs from the plugin UI leave the projects behind. The tests - need nothing set up beforehand. -- Assigning `name`, `pinned` or `shared` updates the object at once and saves - in the background. To check the saved result, list again inside - `waitForAsync()` (`src/tests/wait.ts`). + `temporaryName()` (`src/framework/temporary.ts`). The API cannot delete + teams, so the live CI driver deletes the teams and projects with that prefix + after the run. Runs from the plugin UI leave them behind. The tests need + nothing set up beforehand. +- Assigning `name`, `pinned` or `shared` saves to the backend first and updates + the object only when the save succeeds. Wait for the new value with + `waitFor()` (`src/tests/wait.ts`) before reading it; to check what the + backend stored, list again inside `waitForAsync()`. +- `Project.fileCount` is the count when the project was listed; creating or + moving files does not change it. List the project again to see the new + count. - Shape, page, file and library objects taken before `file.open()` (or `openFile`) are stale afterwards; look them up again in the new file. `ctx.board` belongs to the file the test started in, so only use it there. diff --git a/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts index 1c451f5ac1..67e96d83b6 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/management.test.ts @@ -193,6 +193,7 @@ describe('Management', () => { try { team.name = renamed; + await waitFor(() => team.name === renamed, { timeout: 5000 }); expect(team.name).toBe(renamed); await waitForAsync( async () => (await currentTeam(ctx)).name === renamed, @@ -225,7 +226,9 @@ describe('Management', () => { expect(file.projectId).toBe(created.id); expect(file.teamId).toBe(team.id); expect(file.shared).toBe(false); - expect(created.fileCount).toBe(1); + // fileCount is a listing snapshot, so list the project again. + expect(created.fileCount).toBe(0); + expect((await listedProject(ctx, created.id))?.fileCount).toBe(1); expect((await created.listFiles()).map((f) => f.id)).toEqual([file.id]); // Creating does not navigate. expect(ctx.penpotMgmt.workspace).toEqual(home); @@ -240,6 +243,9 @@ describe('Management', () => { created.name = renamed; created.pinned = true; + await waitFor(() => created.name === renamed && created.pinned, { + timeout: 5000, + }); expect(created.name).toBe(renamed); expect(created.pinned).toBe(true); @@ -282,20 +288,18 @@ describe('Management', () => { name: temporaryName('team'), }); - try { - const moved = await team.createProject({ - name: temporaryName('moved'), - }); - await moved.moveTo(target); + // Teams cannot be deleted through the API; the live CI driver deletes + // the temporary team after the run. + const moved = await team.createProject({ + name: temporaryName('moved'), + }); + await moved.moveTo(target); - expect(moved.teamId).toBe(target.id); - expect((await target.listProjects()).map((p) => p.id)).toContain( - moved.id, - ); - expect(await listedProject(ctx, moved.id)).toBeUndefined(); - } finally { - await target.remove(); - } + expect(moved.teamId).toBe(target.id); + expect((await target.listProjects()).map((p) => p.id)).toContain( + moved.id, + ); + expect(await listedProject(ctx, moved.id)).toBeUndefined(); }); }); @@ -305,6 +309,7 @@ describe('Management', () => { const parent = await temporaryProject(ctx); file.name = ' Renamed '; + await waitFor(() => file.name === 'Renamed', { timeout: 5000 }); expect(file.name).toBe('Renamed'); await waitForAsync( async () => (await listedFile(parent, file.id))?.name === 'Renamed', @@ -361,6 +366,9 @@ describe('Management', () => { expect(ctx.penpotMgmt.workspace.fileName).toBe(`${original} (renamed)`); } finally { file.name = original; + await waitFor(() => ctx.penpotMgmt.workspace.fileName === original, { + timeout: 5000, + }); } }); }); @@ -460,6 +468,7 @@ describe('Management', () => { ); file.shared = true; + await waitFor(() => file.shared, { timeout: 5000 }); expect(file.shared).toBe(true); await waitForAsync(isListed); expect(await isListed()).toBe(true); From 9d898b8b31f8313b93d1f1f34b601f4d4e65ebf7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 5 Nov 2026 08:45:01 +0100 Subject: [PATCH 233/325] :wrench: Normalize the tenant to lowercase (#12843) An operator writing `PENPOT_TENANT=ACME` and one writing `acme` name the same tenant: the value is compared as an exact string in the keys and rows it lands in, and one uppercase letter would otherwise split one tenant into two. The value is normalized when the configuration is read, so nothing downstream has to care about case, and the validated form accepts only lowercase labels from then on. AI-assisted-by: deepseek-v4.1-flash --- backend/src/app/config.clj | 20 +++++++++++++++++--- backend/test/backend_tests/config_test.clj | 11 +++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 515f3bec49..99f47ea40c 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -111,8 +111,14 @@ :ssrf-extra-blocked-cidrs #{}}) (def schema:tenant - "Tenant identifier: hostname-label-style (letters, digits and - hyphens). It is interpolated into Redis keys separated by dots (msgbus + "Tenant identifier: hostname-label-style, lowercase (letters, digits + and hyphens). The value is normalized to lowercase when the + configuration is read, so an operator writing `PENPOT_TENANT=ACME` and + one writing `acme` name the same tenant: the value is compared as an + exact string in the keys and rows it lands in, and one uppercase + letter would otherwise split one tenant into two. + + It is interpolated into Redis keys separated by dots (msgbus topics, rate-limit buckets, exporter job keys, which are also matched with a glob pattern), so `.` and whitespace are rejected at startup because one tenant would then be able to read or overwrite another's @@ -125,7 +131,14 @@ tenant asking for it, and a tenant that has one would have to be renamed. Tenants using those characters must be renamed before upgrading." - [:re #"^[A-Za-z0-9-]+$"]) + [:re #"^[a-z0-9-]+$"]) + +(defn- normalize-tenant + "Lowercase the configured tenant before it is validated, so + `PENPOT_TENANT=ACME` and `acme` mean the same tenant everywhere." + [config] + (cond-> config + (string? (:tenant config)) (update :tenant str/lower))) (def schema:config (do #_sm/optional-keys @@ -401,6 +414,7 @@ [& {:keys [prefix default] :or {prefix "penpot"}}] (->> (read-env prefix) (merge default) + (normalize-tenant) (decode-config))) (def version diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index af06598962..74a159a703 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -6,10 +6,21 @@ (ns backend-tests.config-test (:require + [app.common.schema :as sm] [app.common.time :as ct] [app.config :as cf] [clojure.test :as t])) +(t/deftest tenant-is-normalized-to-lowercase + (t/testing "an uppercase tenant names the same tenant" + (t/is (= "acme" (:tenant (#'cf/normalize-tenant {:tenant "ACME"})))) + (t/is (= "acme" (:tenant (#'cf/normalize-tenant {:tenant "acme"}))))) + (t/testing "a config without a tenant is left untouched" + (t/is (= {:x 1} (#'cf/normalize-tenant {:x 1})))) + (t/testing "the validated form is lowercase-only" + (t/is (not (sm/validate cf/schema:tenant "ACME"))) + (t/is (sm/validate cf/schema:tenant "acme")))) + (t/deftest get-public-uri-normalizes-base (t/testing "trailing slash is ensured with and without subpath" (doseq [[base expected] [["http://localhost:3449" "http://localhost:3449/"] From 657ce8b05440bfb06cd4f366b9674098f47d13b4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 5 Nov 2026 11:30:23 +0100 Subject: [PATCH 234/325] :bug: Make the login dialog section links keyboard buttons (#12724) "Forgot password?", "Create an account" and "Login here." switch the section of the login dialog (viewer and MCP authorize) and of the dialog on the not found page. They were without href, so Tab skipped them. They become