diff --git a/.agents/README.md b/.agents/README.md index 657ab6eac3..371444a89d 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -410,6 +410,10 @@ append-only `Review Log` (UTC ISO 8601, one line per entry): A plan approved with no changes goes `draft` → `done` directly. +A plan is never staged or committed: the `done` status and its +`Review Log` are session bookkeeping, not part of the change. Their +absence from the commit is expected. + ## 7. Connecting `gh` CLI with a token The `create-issue` and `create-pr` flows need an authenticated `gh` diff --git a/.agents/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md index f0a612c17e..dd85191fd9 100644 --- a/.agents/skills/create-commit/SKILL.md +++ b/.agents/skills/create-commit/SKILL.md @@ -23,10 +23,11 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. ## Iron Rules (non-negotiable) -1. **Wrap every body line at 76 characters or fewer.** Count characters, do +1. **Wrap every body line at 80 characters or fewer.** Count characters, do not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and lines carrying a URL. This is the rule agents skip most often. -2. **Subject ≤70 chars**, imperative, capitalized, no trailing period. +2. **Subject ≤80 chars**, imperative, capitalized, no trailing period. + `Merge`/`Revert`/`Reapply` commits skip the length check. 3. **Blank line between subject and body.** 4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically checks rules 1–3. A non-zero exit is a hard blocker: fix the message and @@ -41,7 +42,7 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. that does not match the stated intent, **STOP** and tell the user before committing. 3. Draft the message following the format in the memory doc, wrapping the body - at 76 characters per line, and run: + at 80 characters per line, and run: ```bash git commit -m "" -m "" ``` @@ -62,5 +63,6 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. - Do not pass `--author`. Author identity comes from the local git config. - Do not amend a commit you did not create in this session, unless explicitly asked. - Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked. +- Never add a `Co-authored-by:` trailer. It is forbidden and fails `./scripts/check-commit`. - Do not add untracked files that were not created in this session. - Do not skip the `scripts/check-commit` verification step (Iron Rule 4). diff --git a/.agents/skills/rewrite-commit-message/SKILL.md b/.agents/skills/rewrite-commit-message/SKILL.md new file mode 100644 index 0000000000..3281d0fa75 --- /dev/null +++ b/.agents/skills/rewrite-commit-message/SKILL.md @@ -0,0 +1,38 @@ +--- +name: rewrite-commit-message +description: Rewrite HEAD's commit message (typically a squash that concatenated old messages) into one proper title plus description following Penpot commit conventions, preserving every distinct AI-assisted-by attribution. Amends in place, never pushes. +slash: true +--- + +# Skill: rewrite-commit-message + +HEAD's message is a concatenation of older messages (typical after a +squash): several subjects, bodies and repeated trailers stacked in a +single message. Replace it with one proper message and amend HEAD. + +## When to Use + +- The user asks to rewrite, retitle or fix the last commit message +- After a squash left HEAD with stacked messages and trailer repeats + +## Steps + +1. Read the full current message (`git log -1 --format=%B HEAD`) and + the branch diff (`git diff develop..HEAD --stat`, plus the code + when the summary needs grounding). +2. Collect every DISTINCT `AI-assisted-by:` value in the current + message. All of them survive into the new message, one line each: + attributions are never dropped in a rewrite, even when most of + the old text is. +3. Draft one message per `mem:workflow/creating-commits` (read it + end-to-end first): single subject (imperative, capitalized, no + trailing period, ≤80 chars), blank line, body wrapped at ≤80 + chars covering the whole diff (not the old per-commit slices), + then the preserved trailers. +4. Write it to a file (a heredoc keeps real newlines; inline `-m` + with `\n` does not) and `git commit --amend -F `. +5. Run `./scripts/check-commit` and require exit code 0. A non-zero + exit is a hard blocker: fix the message, amend again. +6. Report the new hash. Never push: the user pushes from their own + shell. Warn when local history was rewritten over pushed commits + (the push will need force — their call, not yours). 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/build-bundle.yml b/.github/workflows/build-bundle.yml index 88c04a2634..7c66eb9fea 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,26 @@ jobs: env: GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | + # 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=$(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}-${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 - # 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 +108,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 +122,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 +130,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 +174,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 +190,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..613f806571 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,15 @@ 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) + VERSION_TAG=$(git describe --tags --match '*.*.*' --abbrev=0) 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}-${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 @@ -123,14 +129,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 +148,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 +164,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 +237,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 +280,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 +311,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 +328,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 +381,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 diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 29d9c79d60..d245e6cd14 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -47,4 +47,46 @@ jobs: # pattern: '^.+(Resolves|Fixes): \#[0-9]+$' # error: 'You need at least one "Resolves|Fixes: #" line.' + check-commit-guidelines: + if: ${{ !github.event.pull_request.draft }} + name: Check Commit Guidelines + runs-on: ubuntu-latest + steps: + # On PR events check out the base ref, so the script below is + # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ github.event.pull_request.base.sha || github.sha }} + + - name: Fetch PR commits + if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} + run: git fetch --no-tags origin "pull/${{ github.event.pull_request.number }}/head:pr-head" + + - name: Run scripts/check-commit on every new commit + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + RANGE="${{ github.sha }}" + else + RANGE="${{ github.event.before }}..${{ github.sha }}" + fi + else + RANGE="${{ github.event.pull_request.base.sha }}..pr-head" + fi + COMMITS="$(git rev-list --reverse "$RANGE")" + if [ -z "$COMMITS" ]; then + echo "No new commits to check." + exit 0 + fi + FAILED=0 + for COMMIT in $COMMITS; do + ./scripts/check-commit -c "$COMMIT" || FAILED=1 + done + exit "$FAILED" + 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 3bd9470178..f513ba6af9 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -48,10 +48,10 @@ 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.21.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 0cba3f685d..65ed5c80f3 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -46,10 +46,10 @@ 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.21.0 + runtime: node@24.22.1 install: false - name: Install deps 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 diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index ed0da17b28..82b7b12f73 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -61,8 +61,3 @@ 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 eac2d7ebf3..f3e1547321 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -60,13 +60,3 @@ 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/.gitignore b/.gitignore index 8bb5a53cf9..5e38fb2361 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,10 @@ .repl opencode.json .opencode/package-lock.json +/*.html /*.jpg +/*.json +!/package.json /*.md !CHANGES.md !CONTRIBUTING.md @@ -92,6 +95,12 @@ opencode.json /playwright/.cache/ /render-wasm/target/ /render-wasm/.render-wasm-build.lock + +# render-wasm build artifacts copied into the exporter (autogenerated) +/exporter/resources/wasm/render-wasm.js +/exporter/resources/wasm/render-wasm.wasm +/exporter/src/app/wasm/shared.js + /media-processor/dist/ /media-processor/target/ /**/node_modules @@ -114,5 +123,6 @@ opencode.json /.ci-logs /tmp/ /.codex/ +/scripts/__pycache__/ /tools/__pycache__ /scripts/__pycache__ diff --git a/.nvmrc b/.nvmrc index 166aae1151..474d750b50 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.21.0 +v24.22.1 diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 8097bd901c..a9766ec80b 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 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): @@ -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`, 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): -- 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 `(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` → (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 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 @@ -118,6 +120,21 @@ Critical `profile-id` INVARIANT: the event belongs to **whoever made the request The `::audit/profile-id` override goes through `coerce-profile-id`: a string is parsed, anything that cannot become a uuid is dropped (with a warning) and the event falls back to the caller. Do not pass the value raw: `schema:event` needs a uuid and `submit*` swallows the validation error (it only logs, the RPC still succeeds), so a non-uuid override **loses the row silently**. Hand-built events with `event-from-rpc-params` + `submit` (clone template, accept team invitation, `management/nitrate` push-audit-events) do **not** go through that coercion: they must already carry a uuid. +### Team id and entity ids (projection data) + +The event stream is meant to let a consumer (Nexus) rebuild what it needs **without joining production tables**, so events carry the ids a projection keys on: + +- **Team id enrichment.** After `clean-props`, `prepare-rpc-event` calls `enrich-team-id`: when props have no `:team-id` but carry a `:project-id` or `:file-id`, it resolves the team with a local query and adds `:team-id`. It never resolves from `:id` (read commands put the entity id there, and that would add a query to every read), and it skips read events entirely (`get-*` names plus `has-file-libraries`: their events carry no consumer state). Commands that already loaded the team add `:team-id` themselves in `::audit/props` (`delete-file`, comments, media upload, `delete/update-webhook`, `clone-template`, ...), so the resolver does not run. +- **Membership.** `add-profile-to-team!` emits `add-team-member` (props `:team-id`, `:member-id`, `:role`, `:is-owner`, `:is-admin`, `:can-edit`) whenever it actually inserts a row, only with `:audit-log` on, inside the caller's transaction. It covers the flows that add a member with no event of their own (`create-team` owner, invitee added directly when `:email-verification` is off, approved join-request, duplicated team). With `on-conflict-do-nothing?` a conflict returns nil and emits nothing, so an existing membership is reported once. The event belongs to the actor: the two flows where the owner adds somebody else (`create-invitation`, `add-member-to-team`) pass `:actor-id` through a separate `event-opts` arg (never in `params`, which is inserted as-is); everywhere else actor and member coincide. `:role` is derived in the emitter from the flags (inverse of `permissions-for-role`: owner > admin > editor > viewer) because the table has no role column; request context travels in `event-opts` as `:rpc-params`. +- **Created entity ids.** `create-project` and `create-file` add the new row's `:id` (and `:project-id`/`:team-id`) to `::audit/props`, so a projection can key it. +- **Bulk operations.** `clone-template`, `import-binfile` and `duplicate-project` create entities through internal calls that emit nothing on their own; they emit a `create-file` (and `create-project`) per entity (only with `:audit-log`). The import paths go through `audit/submit-file-created-event`, which reads the light columns (`id`, `name`, `project-id`, timestamps, `revn`) back from the row — the file must really exist, a phantom id emits nothing. `duplicate-project` already holds the rows and uses `audit/submit-create-event` directly. Both binfile versions return the same shape from `import-files!` (`{:file-ids [...]}`). +- **Deletes.** `delete-team` still returns nil but carries the deleted row (`:id`, `:name`, `:created-at`, `:modified-at`, `:deleted-at`) in `::audit/props`, like `delete-project`. `delete-profile` carries `:id`, `:email`, `:fullname`, `:created-at`, `:modified-at` and `:deleted-at` (without this its props are empty: every param is namespaced and `clean-props` drops them). `create-team` adds `:created-at` and `:modified-at`. `delete-team-member` needs nothing: the delete is physical and the params already carry `:team-id`/`:member-id`. +- **Hard deletes.** `objects-gc` emits one `hard-delete-objects` event per purged chunk, carrying only that chunk's entity key (`:team-ids`, `:project-ids` or `:file-ids`; one chunk transaction purges a single entity, so one event can never span sections) with context `{:triggered-by "objects-gc"}`, only with `:audit-log` on. No other GC path emits (profiles, fonts, thumbnails); those purges stay silent. +- **SREPL deletes.** `delete-profile!` carries the subject id in props (it used to emit nothing usable); the event profile stays zero because SREPL has no actor. `delete-team!` props already carried the id. Both travel as actions with a `triggered-by: srepl` context. +- **SREPL restores.** `restore-profile!` carries the full row (with id) in props with a zero event profile; `restore-team!` and `restore-project!` already carried their ids. The consumer resolves the subject from props by a uniform rule, with no SREPL special case. +- **Cascades.** The `:delete-object` worker task emits one `delete-object-with-cascade` event per execution, type `action`, with `:object`/`:id`/`:deleted-at` plus the affected ids nested under `:cascade` (`:team-ids`, `:project-ids`, `:file-ids`), in the same transaction. The handler submits unconditionally (`submit` fans out per flag, so no `:audit-log` re-check); the `audit_log` row itself is still only written with `:audit-log` on. Direct calls (objects-gc) and snapshot cascades stay silent on purpose. +- **Ownership.** `create-team-with-invitations` carries the new team `:id` like `create-team`; `update-team-member-role` names the demoted member in `:prev-owner-id` on owner transfers. + Entry points: - `submit`: the normal one (fills defaults, validates, runs inside `tx-run!`, never breaks the RPC). @@ -223,6 +240,8 @@ Webhooks let an external URL get notified when something happens in a team (for One guarantee: sending webhooks never changes the audit event itself. If delivery fails, only the webhook (and its delivery log) is affected; the audit row stays untouched and still gets archived normally. +Every match also writes a `webhook` / `trigger` row to `audit_log` (even with zero matches): its props are `{:name , :event-id , :total-affected }`. `process-event` assigns the origin row id up-front so the trigger can reference it; the telemetry shadow row always gets its own id (reusing it would collide on the PK). + ## 10. Consumers II: error reporters Both `app.loggers.database` and `app.loggers.mattermost` listen to backend `:error` logs **and** frontend crash events (found by the `::audit/event` marker), through a sliding-buffer channel so a flood cannot stall the app. The database one stores them in `server-error-report` (source 3 = backend log, 4 = audit-log origin, 5 = rate-limit); the Mattermost one forwards a short note to `:error-report-webhook` when set. @@ -252,17 +271,21 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert - **Anonymization happens in Penpot, before sending.** By the time events leave, times are day-truncated, IPs are already `0.0.0.0`, props/context are filtered (see above), and emails are gone (only derived `email-domain` survives). Nexus additionally forces the outcome: it prefixes any non-`telemetry:` source, sets `received-at = tracked-at` (already truncated), pins IP to `0.0.0.0`, and stamps the sending `instance-id` into every event context. - **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`). Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs). +- **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-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`). 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/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) @@ -272,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 consumer -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-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). - **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-`): @@ -293,29 +312,31 @@ 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-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 - **New backend command that must (or must not) emit**: `wrap-audit` does it alone; to exclude it set `::audit/skip`. To customize props/name/type use result metadata. Add a test mocking `app.loggers.audit/submit` (nil; `helpers.clj` stubs it globally) and assert `:call-args-list` — see `backend_tests/rpc-audit-test.clj` (full-row, telemetry-only, dual, no-op without flags, `insert`, `prepare-rpc-event`) and `rpc-team-test/accept-organization-invitation-audit-event` (three cases, exact props; counts matter: asserting with `first` passes even when a command emits the same name twice). - **New Penpot frontend event**: create the data-event or implement `Event`/`PerformanceEvent` in `app.main.data.event`, with `::name`/`::type`/`::origin`. Honor `skip-audit?` if the action can resume. Remember: with no props schema, **your asserts are the contract**. - **New admin-console event**: server action → add the backend-log call next to the mutation, best-effort so it never breaks the action; browser action → add a new event helper plus its test. +- **Command that creates an entity or already knows its team**: put the new row's `:id` in `::audit/props` (`create-project`/`create-file`) and set `:team-id` when the team is loaded, so the consumer never has to join production tables. For entities created inside an internal call (`clone-template`, `import-binfile`, `duplicate-project`), emit one event per entity: `audit/submit-file-created-event` when only the id is known (it reads the row back), `audit/submit-create-event` when the row is already in hand. - **Never** put secrets in `props`; `clean-props` is a last defense, not an excuse. - **Do not duplicate events**: if the backend already emits an event for an action, do not add a frontend (or admin-console browser) event for the same action — the backend one wins because it is reliable. A second event for the same action is only acceptable with a strong reason (for example the backend cannot see it at all), and then it must be clearly justified. - **Follow the naming policy**: use kebab-case names (`"create-organization"`, not `"createOrganization"`), and before inventing prop keys, look at similar existing events and reuse their key names (`organization-id`, `team-id`, `file-id`, `event-origin`...). Same concept, same key spelling — that is what keeps the data queryable later. ## 15. Code map -- Backend producer: `backend/src/app/loggers/audit.clj` (`submit`, `submit*`, `insert`, `prepare-rpc-event`, `event-from-rpc-params`, telemetry filters), `backend/src/app/rpc.clj` (`wrap-audit`), `backend/src/app/rpc/commands/audit.clj` (`push-audit-events`, `get-environment-data`, deprecated `get-enabled-flags`), `backend/src/app/rpc/management/nitrate.clj` (`push-audit-events` Nitrate→Penpot), `backend/src/app/http/middleware.clj` + `backend/src/app/http/management.clj` (shared-key auth). +- Backend producer: `backend/src/app/loggers/audit.clj` (`submit`, `submit*`, `insert`, `prepare-rpc-event`, `enrich-team-id`, `submit-create-event`, `event-from-rpc-params`, telemetry filters), `backend/src/app/rpc.clj` (`wrap-audit`), `backend/src/app/rpc/commands/audit.clj` (`push-audit-events`, `get-environment-data`, deprecated `get-enabled-flags`), `backend/src/app/rpc/management/nitrate.clj` (`push-audit-events` Nitrate→Penpot), `backend/src/app/http/middleware.clj` + `backend/src/app/http/management.clj` (shared-key auth). +- Membership emitter: `backend/src/app/rpc/commands/teams.clj` (`add-profile-to-team!` → `add-team-member`). - 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/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/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index ddc3aac31a..2a58e49dfd 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -13,7 +13,7 @@ - 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. -- HTTP/session parsing details such as cookie/header precedence and SameSite behavior are in `mem:backend/subtleties`. +- 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 ef916f93a4..2d44a88b76 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -4,7 +4,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Focused memories -- Cross-cutting backend subtleties (RPC, DB, workers, cron, CLIMIT, HTTP/sessions, storage, file data): `mem:backend/subtleties` +- Cross-cutting backend subtleties (RPC, DB helpers, workers, cron, CLIMIT, config and HTTP/session middleware, http self-metrics, media processing, and file data persistence): `mem:backend/subtleties` - Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`. - Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment` - Session lifetime config, token `:exp`, and idle/absolute GC: `mem:backend/session-expiration`. @@ -49,13 +49,15 @@ Backend RPC command areas without focused memories include access tokens, binfil 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. +Migrations must stay within PostgreSQL 15 syntax: the official docker images (`docker/images/docker-compose.yaml`) pin `postgres:15`, so anything newer breaks those deployments (`SET STORAGE DEFAULT` is PostgreSQL 16, for instance). The documented floor is >=15. + 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 (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 +66,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/storage.md b/.serena/memories/backend/storage.md index beb591e810..529fcd1cc8 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -25,8 +25,9 @@ - Existing object values can remain readable until physical deletion. - `:expired-at` blocks reads after the expiration time. - `del-object!` sets `deleted_at` on live rows only (`deleted_at IS NULL`): a repeated call returns `false`. It does not remove backend content. -- `storage-gc-deleted` removes the database row and backend content after the deletion delay, streaming the pending rows with `db/plan` inside a read-only transaction and deleting them in chunks of 25, each chunk in its own transaction: a chunk that fails (for example, an object a foreign key still references) is logged and skipped instead of aborting the run. A physical-deletion failure inside a chunk defers its rows one day (`deletion_attempts + 1`) and gives up after 7 attempts, accepting the orphan blob; chunk mappings (`upload_session_chunk`) are removed before the `storage_object` rows (NO ACTION foreign keys). +- `storage-gc-deleted` removes the database row and backend content after the deletion delay, processing the pending objects in chunks of 25 with keyset pagination (`(deleted_at, id)` cursor, matching the `storage_object(deleted_at, id)` index). Only `status = 'valid'` objects are read: pending ones (still being written, see the status lifecycle) stay untouched even when already marked as deleted. Each chunk is one short read-write transaction: `SELECT ... FOR UPDATE SKIP LOCKED` (so two concurrent runs never take the same chunk; no separate read transaction exists and the run does not block the table vacuum) followed by the database delete and the backend delete grouped by backend. The cursor advances to the `(deleted_at, id)` of the last row read regardless of the deletion result, so a chunk that fails is skipped and not visited again in the same run: the error aborts the transaction (nothing of the chunk is deleted, locks are released), the handler catches it inside the transaction function and rolls it back explicitly — the commit that `tx-run!` performs afterwards is a JDBC-driver no-op on the already-rolled-back connection — and the loop continues with the returned rows. Accepted quirks, both benign and self-healing: (1) with rows of several backends in one chunk, a failure after one group's physical delete leaves those rows pending with already-removed content — pending rows are excluded from reads and the next run finishes them, since the physical delete is idempotent (S3 ignores missing keys, FS uses `deleteIfExists`); (2) rows locked by a concurrent run are skipped via `SKIP LOCKED` and deferred to the next scheduled run, and the `deleted_at <= now` cutoff is sampled per chunk. It emits the `penpot_storage_gc_deleted_objects_total{backend}` counter (one increment per deleted backend group, reported only after the chunk commits, so rolled-back chunks are never counted twice); run duration is covered by the generic `penpot_tasks_timing{name="storage-gc-deleted"}` histogram and chunk failures by the log-derived `penpot_log_events_total`. Every 5000 deleted objects the run pauses 5 seconds (throttle between chunk transactions, never holding one open). It runs as the `storage-gc-deleted` job (`::job-def`) and beats `jobs/heartbeat` after each committed chunk, so a long sweep keeps its lease and never holds it during a chunk transaction. - `storage-gc-touched` finds references before it sets `deleted_at`. +- Orphan watch: `SELECT COALESCE(metadata->>'bucket', metadata->>'~:bucket') AS bucket, count(*) FROM storage_object so WHERE touched_at IS NULL AND deleted_at IS NULL AND status = 'valid' AND NOT EXISTS (SELECT 1 FROM upload_session_chunk usc WHERE usc.object_id = so.id) GROUP BY 1;` Only the `upload-session` row of that output is a leak signal (its rows always have a mapping or a pending touch/deleted mark); other buckets use domain-table references, not this mapping. - `objects-gc` removes deleted domain rows and touches their storage object IDs. Each proc streams its pending keys with `db/plan` inside a read-only transaction and deletes them in chunks, each chunk in its own transaction with `FOR UPDATE SKIP LOCKED`: a chunk that fails is logged and skipped instead of aborting the run. - Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. @@ -34,7 +35,7 @@ - `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 the call order in `app.tasks.objects-gc/execute-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. The cursor that streams the pending keys runs in a read-only transaction of its own (`db/plan`), and every chunk is deleted in a transaction of its own: the cursor fetch and the deletes are never the same transaction. +- `objects-gc` sweeps in chunks of 100 (`::chunk-size`, overridable per job params); `storage-gc-deleted` in chunks of 25. `objects-gc` (and the other `db/plan` sweeps) stream the pending keys in a read-only transaction of their own and delete every chunk in a transaction of its own: the cursor fetch and the deletes are never the same transaction. `storage-gc-deleted` has no separate cursor transaction: it reads, locks and deletes each chunk in the same short transaction (see the keyset pagination above). - 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 @@ -91,7 +92,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + ` | `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. | | `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. | | `tempfile` | Export files and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. | -| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | No reference scan. A touched object is deleted after the delay; `gc-deleted` removes mappings before rows. | +| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | Reference scan on `upload_session_chunk.object_id`; a referenced chunk freezes, an orphan is deleted after the delay. Deferred rows keep their mappings until give-up. | | `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. | @@ -124,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/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 18af2954aa..a3cf848ace 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 @@ -52,7 +52,7 @@ - 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`. +- 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. @@ -72,9 +72,15 @@ - `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. +- `penpot_http_malformed_requests_total` (counter, no labels) counts requests whose body the parser could not read. `app.http.middleware/wrap-parse-request` emits it where it classifies the error, so the instance travels as a middleware argument (`[mw/parse-request (::mtx/metrics cfg)]`) and each service declares the definition with its own prefix (nexus: `nexus_http_malformed_requests_total`). The per-endpoint request counters of nexus do not count these requests: they sit inside the parse middleware, so the failure happens before them. + +## Log-derived metrics + +- `app.loggers.metrics/reporter` counts the log records the application emits and publishes them as `penpot_log_events_total{logger, level}` (counter), so the error panels do not have to count text in Loki. It is a component that hooks `add-watch` on `app.common.logging/log-record`, like `app.loggers.database`, but with no thread and no channel: the increment is in memory, so it goes straight in the watcher. Only `warn`, `error` and `fatal` are counted, and only the records that go through `app.common.logging` (the application's own logs; a library writing straight to log4j2 is not counted, and today that is one line in a thousand). The body of the watcher is wrapped in a `try/catch` that swallows the error on purpose: the watcher fires from the `swap!` of the log record, inside the logging path. With `::l/sync?` (`app.binfile.v1`, `app.msgbus`) an exception there would reach whoever was logging; on the async path it escapes to the logging executor worker, whose trace goes to the thread's uncaught handler -- outside the application's logging -- and the pool replaces the dead worker, so logging continues and that line is lost. The catch also makes a publication that fails every time invisible: `penpot_log_events_total` stays flat and nothing is written about it. The published `level` values are lowercase (`warn`), unlike the `level` label of the Loki logs (`ERROR`), so a panel that moves from one source to the other has to change it. Cardinality is `logger` (fixed names taken from the code) times `level`; a logger built from a dynamic value would break that. ## Storage and media +- The `media-storage-bytes-per-team` quota check on `upload-file-media-object` is TEMPORARILY DISABLED (2026-10-19, kept commented in `app.rpc.commands.media` with its `sql:get-team-id-for-file` lookup still active for the audit props): the quote performs a full `SUM` over `storage_object` per upload and was too slow in production. The `upload-sessions-per-profile` quota is unaffected and stays active. Re-enable only with a cheaper approximation. - Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. - SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. - Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. @@ -86,4 +92,12 @@ - File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. - `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. - Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. -- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. \ No newline at end of file +- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. +- A `file_data` row is one of three classes, told apart by `type`: `main` is the file's current state and carries `id = file_id`; `snapshot` is a point-in-time copy whose `id` is the one its `file_change` row shares; `fragment` is a piece of the pointer-map, referenced from the file's data. +- Their lifecycles differ: `main` and `snapshot` live with the file (dated when it is deleted, undated when it is restored), while a `fragment` carries no date and is collected by `file_gc` once the file stops referencing it. Neither is a session buffer, so do not read a `fragment` as an ephemeral revision. +- File snapshots (versions) keep their metadata row in `file_change` and their payload in `file_data` (`type = 'snapshot'`). With the `db` backend `file_change.data` stays NULL: that column is only written by the `legacy-db` backend, plus the rows written before the 2025-07 file storage refactor. +- Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. +- The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again. +- Revisions live only in `file_change` and have no `file_data` counterpart. They are born already scheduled for deletion one hour later (`app.rpc.commands.files-update`) and the snapshot paths cannot reach one, because they resolve their row through a `label IS NOT NULL` filter. +- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. +- Binfile v1/v3 import and the v3 export `get-file` step run inside one long transaction. They raise `idle_in_transaction_session_timeout` to 30 min (`app.binfile.common/transaction-idle-timeout-ms`) instead of disabling it, so a connection left idle while the ZIP is decoded or the file data is realized is bounded; `statement_timeout` stays at 300 s. diff --git a/.serena/memories/clojure/design-rules.md b/.serena/memories/clojure/design-rules.md index cac863cc16..2b439ee038 100644 --- a/.serena/memories/clojure/design-rules.md +++ b/.serena/memories/clojure/design-rules.md @@ -2,10 +2,10 @@ How to shape a function in this codebase. Each rule came from a review round; do not re-derive from taste. -- **Preconditions belong to the boundary, not to the core.** A function documents what it needs (hints, docstring) and assumes it. Absence checks live where the value enters the system: the caller that resolves optional objects, `ig/init-key`, the request handler. A `(when (and (some? a) (some? b)) ...)` inside a leaf function means the guard is in the wrong place — if you cannot get `a` or `b`, you should not be calling it. -- **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`. +- **Preconditions belong to the boundary, not to the core.** A function documents what it needs (hints, docstring) and assumes it. Absence checks live where the value enters the system: the caller that resolves optional objects, `ig/init-key`, the request handler — including `nil` that is optional-by-design (`when-let [cs (some-> server ...)]` at the entry point, as with `ConnectorStatistics` when the server option is off). A `(when (and (some? a) (some? b)) ...)` inside a leaf function means the guard is in the wrong place — if you cannot get `a` or `b`, you should not be calling it. - **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. +- **Functions that obtain data start with `get-` or `fetch-`.** A reader must see at a glance that the function reads (`get-file`, `get-snapshot`, `get-file-json`); a bare noun hides it (`file-json` says nothing about reading). New helpers that fetch by id follow this, never a `current-` prefix: the id parameter already says which one it is. - **`?` 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. diff --git a/.serena/memories/clojure/idioms.md b/.serena/memories/clojure/idioms.md index d7f40ce740..7e81fba7ac 100644 --- a/.serena/memories/clojure/idioms.md +++ b/.serena/memories/clojure/idioms.md @@ -10,3 +10,4 @@ Behaviors confirmed against the language/stdlib — do not re-derive from assump - `^: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). +- `cuerdas.core/index-of` returns `nil` (not `-1`) when the substring is absent, like `clojure.string/index-of`. `(pos? nil)` **throws on the JVM** but evaluates to `false` in CLJS (JS coercion), so `(if (pos? (str/index-of s "\n")) …)` works in the browser and crashes the backend. Guard with `some?` before `pos?` (this was `app.common.exceptions/first-line`). diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 64141eed15..5804c47212 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -7,15 +7,16 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Edits/stale refs/duplication cleanup: `mem:memory-maintenance`. - Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`. - Verified Clojure language behaviors that contradict common assumptions (e.g. `int?` covers `Long`; `integer?` is the general predicate): `mem:clojure/idioms` — read before assuming stdlib predicate semantics. -- When designing a solution or an API, read `mem:clojure/design-rules` (function shape, boundary checks, naming). +- Read `mem:clojure/design-rules` (function shape, boundary checks, naming) before writing or modifying Clojure/ClojureScript code — not only when designing a solution or an API. # Development workflow - Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples: - - Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer) + - Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 80-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer) - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type) - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line) - Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace layout, `scripts/sync-pnpm-version` flow, the stamp-missing-field and ignored-builds gotchas, verification steps) +- Before a Node version update → `mem:workflow/updating-node` (the six pin sites in `.nvmrc`, the devenv and image Dockerfiles and the plugins-deploy workflows, the DHI tag and digest rules, what to verify) - **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked. - **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request. - You have access to the GitHub CLI `gh` or corresponding MCP tools. @@ -59,6 +60,7 @@ The memory is structured in a way that you can get the critical information abou - `scripts/changelog.py` — Changelog checks for the `update-changelog` skill: `check-merged` (PR merge status), `cross-ref` (milestone PRs vs changelog section), `report` (anomaly report to CHANGES-ISSUES.md). Calls `scripts/gh.py` via subprocess. See `mem:scripts/changelog`. - `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/setup-opencode` — Install the opencode CLI in-container as a global pnpm package in `$PNPM_HOME`. Not part of the devenv image; see `mem:devenv/core`. - `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 d530399fa1..415a69342e 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -24,6 +24,8 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par - Network aliases (`- main`, `- redis`) are not declared in main.yml. Compose's auto-service-alias still registers `redis` on the shared network, so DNS for `redis` is non-deterministic with multiple instances. Backend uses `PENPOT_REDIS_URI=redis://penpot-devenv-wsN-valkey/0` (container_name) instead. - No cross-project `depends_on`. `manage.sh ensure-infra-up` uses Compose `--wait`; PostgreSQL, RustFS, and Mailpit expose healthchecks. PostgreSQL checks TCP so its temporary init server cannot report ready. - `JAVA_OPTS` in `manage.sh` is shadowed inside the container by `_env`. The `-e JAVA_OPTS=...` flag only matters for processes that don't source `_env`. +- opencode is not part of the devenv image. `scripts/setup-opencode` installs the `@opencode/cli` global pnpm package into the penpot user's `$PNPM_HOME` (`/home/penpot/.local/share/pnpm`, inside the user-data volume, so it survives a container re-creation); nothing pins the version in the repo, it tracks the `latest` dist-tag at install time. pnpm 12 puts the global packages in `$PNPM_HOME/global` and the bin dir in `$PNPM_HOME/bin`, and aborts with `ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH` unless that bin dir is on `PATH`; `files/bashrc` and the script both add it. The script exports `PNPM_HOME` itself because non-interactive shells never read `~/.bashrc`. +- `files/entrypoint.sh` chowns penpot's whole `~/.local` and `~/.cache` on every start, because `EXTERNAL_UID` remaps penpot's UID to the host user and build-time ownership then stops matching. That covers pnpm's global dir, store, `package-manager-store` and metadata cache, plus opencode's own state; without it, global installs and pnpm's `packageManager` auto-download fail on permissions. The dotfiles copied from `/root` (`.bashrc`, `.vimrc`, `.tmux.conf`) are chowned in the same pass. `~/.config` stays out on purpose (the opencode overlay bind-mounts a host directory inside it) and so does `/home/penpot` itself, which holds the repo bind mount. ## Worker policy @@ -75,3 +77,42 @@ 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 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 + 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/exporter/core.md b/.serena/memories/exporter/core.md index 9b7078045b..f48948d8d6 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -5,6 +5,7 @@ ## Layout and commands - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. +- `exporter/resources/wasm/render-wasm.{js,wasm}` and `exporter/src/app/wasm/shared.js` are generated render-wasm build output, not source: they are gitignored. Never hand-edit or commit them; regenerate by rebuilding render-wasm. - From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Cross-cutting testing principles and anti-patterns: `mem:testing`. 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/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/.serena/memories/frontend/routing-app-shell-subtleties.md b/.serena/memories/frontend/routing-app-shell-subtleties.md index 989b1d8bad..8c43a30161 100644 --- a/.serena/memories/frontend/routing-app-shell-subtleties.md +++ b/.serena/memories/frontend/routing-app-shell-subtleties.md @@ -14,9 +14,14 @@ - Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload. - Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page. - `app.main.errors/submit-report` is governed by a dedup governor: each report carries a fingerprint (`report-name|type|code|hint|first stack frame`; the report name is part of it so a handled report never coalesces with an unhandled/exception-page one), the first occurrence is always emitted, repeats within 2 minutes are counted and included in the next emitted report as `:occurrences`, and the fingerprint cache is bounded (first-inserted entry evicted, FIFO, via `:order` queue) so memory stays fixed. It applies to `handled-exception`, `unhandled-exception` and `exception-page`; a report without an exception cause is ignored and does not consume a reservation. -- Errors caused by the environment (`environment-error-types`: `:network`, `:offline`, `:bad-gateway`, `:service-unavailable`, `:nitrate-unavailable`, `:nitrate-not-configured`) are not application defects: they are reported as audit-only `handled-exception` (never `unhandled-exception`/`exception-page`, so they skip internal reports and alerts), with a compact report and a fingerprint that drops the stack frame. `:offline` has its own handler and no longer falls through to `:default`; `:network`/`:offline` show the `errors.connection-error` toast. +- Errors caused by the environment (`environment-error-types`: `:network`, `:offline`, `:bad-gateway`, `:service-unavailable`, `:gateway-error`, `:nitrate-unavailable`, `:nitrate-not-configured`) are not application defects: they are reported as audit-only `handled-exception` (never `unhandled-exception`/`exception-page`, so they skip internal reports and alerts), with a compact report and a fingerprint that drops the stack frame. `:offline` has its own handler and no longer falls through to `:default`; `:network`/`:offline`/`:gateway-error` show the `errors.connection-error` toast. +- `repo/handle-response` classifies responses the backend never wrote (body is not a transit map) by status: 504 and Cloudflare 520–524 → `:gateway-error` (retryable, so GETs and saves retry); 429 → `:rate-limit`; any other ≥400 → `:unexpected-response` (both toast-only via `handle-transport-error`, never the exception page). `:internal :unable-to-process-repository-response` is left for a <400 response that does not decode. Uninterpretable bodies are capped at 500 chars in `:data`. - `generate-report` accepts an explicit `{:format :compact|:full}` (default `:full`) chosen by its caller (`flash`, `exception-section*`): `:compact` keeps the context header plus type/code/uri, and skips the stack, the `ex-data` dump and the last-events list. It is total: if formatting fails it returns a minimal fallback string instead of nil, so an already reserved emission is never dropped. - `flash` runs its whole body (report pipeline first, toast after) inside a single `ts/schedule` callback: nothing executes synchronously on the error handler's stack. The report is reserved before being generated, so suppressed occurrences do not pay the `generate-report` cost; a reporting or notification failure is logged to the console and never propagates, and the toast is still attempted. `flash` returns a total promise (never rejects) resolving with the generated report, or nil when nothing is emitted, once the callback completes; production callers ignore it, tests await it. `flash` derives only the payload format from the cause (`environment-error?` → `:compact`); the audit event name is the canonical one requested by `:type` (`handled-exception`/`unhandled-exception`) and is never reclassified, because external tools filter on those names. `exception-section*` picks `handled-exception`/`exception-page` explicitly per cause; `flash-persistence` only adds the toast hint. +- `flash` accepts a `:links` vector of `{:label :callback}` actions, appended after the download link that `:report-link?` produces. `flash-persistence` passes a reload link, so a terminal save failure hands the user both the report and the only action that restarts persistence for the session. `labels.reload-page` plus `js/location.reload` is the established full-restart pattern; the link cannot be invoked under the test runner because `js/location` does not exist there, so it is covered by its label and by being callable. +- `flash-persistence` only delegates to another handler for `:authentication`, `:not-found` and `:code :vern-conflict`. Everything else it reports itself, and the report type is derived from the cause: `#{:validation :server-error :internal}` are application defects and report as `unhandled-exception` so they reach the internal reports and the alerts; the rest stay `handled-exception`. `:cause-type` wins over `:type` when classifying, because `persistence-failed` wraps the original cause under `:type :persistence`. Environment failures are deliberately kept handled: they land here after the retry budget is spent and the user's connection is not our bug. `:assertion` is also kept handled, matching its own handler. +- The report governor is global and stateful across tests: two reports with the same fingerprint inside `report-window-ms` collapse. Call `errors/reset-report-governor!` between assertions that expect an emission. +- Error messages are bounded at the source: `app.common.exceptions/get-hint` clamps to `max-message-length` (1000 chars) and the CLJS `format-throwable` clamps the message line of the stack trace and honors `:trace-length`. A message can embed a whole payload (a serialized file, for example) and reach megabytes; the bound keeps the audit `:hint` prop and the `:report` string small. The `Context: Hint:` header in `generate-report` prints `get-hint` (not the raw `:hint` from `ex-data`) for the same reason. ## Store and websocket @@ -24,4 +29,4 @@ For general store mechanics such as `emit!`, `last-events`, persistence, and und - 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`. -- 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 +- 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). diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index be612121e1..eee6de48b8 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -11,21 +11,30 @@ ## Repo calls - `app.main.repo/send!` uses GET only when the RPC name starts with `get-`, when all params are query params, or for configured special cases. Only GET requests are retried. -- GET retry is limited to transient `:network`, `:bad-gateway`, `:service-unavailable`, and `:offline` errors with exponential backoff. Mutations are not retried. +- `repo/retryable-types` is public and is the single source of truth for the transient transport classification. `persistence/transient-error?` and `errors/environment-error-types` both derive from it; extend that set, never copy it. +- GET retry is limited to transient `:network`, `:bad-gateway`, `:service-unavailable`, `:gateway-error` (504, Cloudflare 52x), and `:offline` errors with exponential backoff. Mutations are not retried. - A server SSE response is only accepted when the command is configured `:stream?`; otherwise it raises an unexpected-response assertion. ## Commits, undo, persistence -- `commit-changes` refuses to create commits unless `:permissions :can-edit` is true. It captures file revn/vern, selected-before, features, tags, undo group, and translation flag into a `::commit` event. +- `commit-changes` refuses to create commits unless `:permissions :can-edit` is true. It captures file revn/vern, selected-before, features, tags, undo group, and translation flag into a `::commit` event. A refusal answers with an empty stream, never nil, so callers subscribe uniformly instead of branching on nil. - Applying a remote commit first rolls back pending local commits, applies the remote changes, then replays pending local redo changes. Index updates are emitted for undo, remote redo, and replayed redo paths. - Local commits are independently consumed by undo, persistence, WASM model updates, thumbnail/library watchers, and text position-data recalculation. - Persistence buffers local commits: status becomes pending after about 200ms, commits are flushed after about 3s or `::force-persist`, and buffered commits are merged per file before `:update-file`. - Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker. - Persistence is skipped in version preview/read-only mode or without edit permission. -- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable` plus `:invalid-save-response`; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then today's terminal path). Resends rotate the `::request-id` stamp only when the old request left `active-requests`, carry the same `:commit-id`, and never double-send while one request is in flight (`:in-flight` stays silent). Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`. +- A terminal failure halts the queue on purpose: the edits stay queued so `wait-persisted-or-error` keeps rejecting, and `workspace/versions.cljs` relies on that to avoid snapshotting a file whose edits were never saved. `recover-persistence` lifts the halt when a file is opened again or the role changes. A transport failure still inside its retry window (`slow-retry-pending?`) is resent once (`resume-persistence true`), never released; any other halt emits `release-failed-file` (drops every queued commit of the file that failed) then `resume-persistence`. Opening a file also replays the queued edits of its version onto the loaded data (`pending-file-changes` → `restore-pending-changes` in `bundle-fetched`); a replay that throws keeps the server data and the queue. That is what unblocks the editor without a reload; because the queue is shared, dropping the failed file is also what lets other files' pending edits drain. `release-failed-file` runs at recovery time, never at failure time — clearing the error eagerly would make waiters resolve. +- The split between `initialize-persistence-state` and `recover-persistence` is deliberate: initialization deliberately does NOT lift a halt, so undeliverable edits are released when the user acts, not when the view mounts. +- **Every non-transport terminal save failure is permanent.** There is no "stale but valid" category: permission lost, file gone, and server-rejected changes all fail again on a resend. Losing edit permission is not temporary — the backend raises `:not-found` for it (`check-edition-permissions!` → `perms/make-check-fn`), and `:not-found` routes to the exception page. So `:save-permission-denied` (a client-side `can-edit` check) must never be resumed: replaying the save to discover that routes the user to the exception page. Release it instead. +- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable`/`:gateway-error`, plus `:invalid-save-response`, plus save-only `busy-server-error?`: `:rate-limit` (429 from rlimit/climit — a save parked on another save's file lock holds the profile's single `:update-file/by-profile` permit, so the user's saves to other files 429 after 60 s) and backend-written 504 `:server-error` with `:statement-timeout`/`:idle-in-transaction-timeout` (Postgres cancels the lock wait at 300 s, before nginx; rolled back, so resend is safe). Kept out of `rp/retryable-types` on purpose so GETs do not retry them; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then the `:error` path). A transient failure that outlasts the backoff arms `slow-retry-cycle`: one silent resend (`:recovering`) every `slow-retry-delay-ms` (30s) until a save lands, `retry-give-up-ms` (24h since `:failing-since`) passes, or the head commit collects `max-slow-failures` (3) attempts that failed after `slow-failure-ms` (90s) or more (`:slow-failures`, counted in `persistence-transient-failure` from the attempt's elapsed time): a gateway timeout may leave the backend applying the save, so a save that keeps failing slowly must not be replayed for a day, while fast failures (offline) never count; the cycle stops on `::resume-persistence`, `::initialize-persistence` and `finalize-workspace`. Leaving the workspace also emits `suspend-retries` (`:suspended` in `:persistence`, drops `:retry-token` so a pending backoff timer goes stale): no backoff, slow cycle or `online` resend runs outside the workspace; only `workspace-initialized` (not a role change) lifts it via `recover-persistence` (`opened-file?`), which also resends a `:retrying` episode whose backoff was cut short. Its toast is `errors.save-still-retrying` without a reload link; a landing save hides the failure toast (`errors/persistence-failed-tag`). Replaying a save is safe because changes are idempotent, so there is no unknown-outcome guard: a commit whose earlier request left `active-requests` is simply sent again under the same `:commit-id`; only a request still in flight blocks a resend (`:in-flight` stays silent). Tests hold the slow cycle by stubbing `rx/timer` to never fire for `slow-retry-delay-ms`, or instant timers loop forever. Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`. - One reconnect notice per episode: sticky toast tagged `:persistence-reconnecting` (single-toast store, re-show replaces), hidden by tag on drain (`:saved`) and on terminal failure; recovery is silent. Header indicator has a `:retrying` state (`workspace.header.retrying`). - Resume triggers: backoff timer and the browser `online` event (guarded by `exists? js/window`; re-enters the runner only for a live `:retrying` episode). New local edits during `:retrying` only join the queue: the episode keeps its `:run-id`, so `append-commit` does not re-enter, and the live runner (still waiting on the head's `commit-persisted`) sends them after the head. Re-entering on edits would bypass the backoff and spend an attempt per batched commit. - Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays. +- `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. +- `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It syncs on `::append-commit`, `::discard-commit` and `::release-failed-file` (every queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. +- `leave-signals` (document `visibilitychange`→hidden, window `blur`) emits `::force-persist` when edits are buffered, so leaving the tab/window sends the 3 s batch at once; tests mock `dps/leave-signals` BEFORE `initialize-persistence` runs. pagehide/beforeunload are too late for an async save. +- Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`. +- `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth/not-found failures. - Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50. - Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path. - After local commits and when render-wasm is active, text shapes get derived `:position-data` recomputed in a separate commit tagged `#{:position-data}`; that tag is excluded from the position-data watcher to avoid loops. diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index 82668cac23..d8ced132ca 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -5,7 +5,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose ## Services - **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`. +- **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`. A burst of client-side `RedisCommandTimeoutException` (2s on `DEL`/`SET`, 10s on `BLPOP`) with the exporter healthy is the JVM paused or starved, not Redis: the timeout fires because nothing reads the reply. - **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 job). - **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`. @@ -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`). @@ -43,6 +43,7 @@ Production runs the deployed toolbox, not the devenv one: - **Start from the ids, not from an inventory.** The ids come from the error or the logs. Unbounded queries over tables with tens of millions of rows (`storage_object`, `file_media_object`) do not finish in a reasonable time; bounded to an id list they are index lookups. - **One query per reference column.** Two columns in one `IN (col_a, col_b)` lose the index and scan the whole table; split them. - The read-only replica cuts long queries: see `mem:prod-infra/grafana`. +- A migration that builds an index runs inside the migration transaction, so it cannot use `CONCURRENTLY`, and a plain `CREATE INDEX` locks the table against writes for the whole build. On a large table (file saves write to `file_change` on every save) build the index out of band, before deploying, with `CREATE INDEX CONCURRENTLY` on its own (never inside a transaction block); the shipped migration uses `IF NOT EXISTS`, finds it and does nothing. A concurrent build interrupted midway leaves the index invalid: drop it and retry (check `pg_indexes` and `pg_index.indisvalid`). ## See also diff --git a/.serena/memories/prod-infra/grafana.md b/.serena/memories/prod-infra/grafana.md index fbc40ec6e5..14324b1f7f 100644 --- a/.serena/memories/prod-infra/grafana.md +++ b/.serena/memories/prod-infra/grafana.md @@ -6,16 +6,23 @@ Keep this memory free of internal names: no host names, no deployed versions, no ## Datasources -- Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. -- Logs — **Loki**, uid `h3CGKhEMk`. LogQL, and filter by the structured metadata `logger`/`level` (`{job="penpot"} | logger="..." | level="ERROR"`). Signals that only exist as logs: the profile behind a CLIMIT queue (`logger="app.rpc.climit"`, whose `id` carries the key). CLIMIT rejections have their own counters (`penpot_rpc_climit_rejected_total`, by `name` and `reason`), and their `hint="rejected"` line goes out at WARN only when something stays queued, so counting the lines is not counting the rejections. -- SQL — **Postgres**, uid `y8ea9tfMk`. Panels need `rawQuery: true`, `editorMode: "code"` and `format: "table"`. The `task` table is the only truth about the queue depth. It is a **read-only replica**: it rejects any write, `CREATE TEMP TABLE` included, so queries through it must be plain `SELECT`s. It also cuts long-running queries (the MCP path has a short timeout), so bound them (e.g. a list of ids); an empty answer there is not proof that there is no data. +- Metrics — **Prometheus**, uid `xkRmyFBGk`. PromQL. Metric families: `penpot_*` (RPC, task timings, CLIMIT, HTTP, websocket, storage, and the application log events of the backend), `hikaricp_*`, `jvm_*` and `process_*` on the backend (`job="app"`); `pg_*` from the Postgres exporter (`job="postgres"`); `redis_*` from the Redis exporter (`job="redis"`); `node_*` and `container_*` from node-exporter and cAdvisor. Backend histograms are in **milliseconds**. Tails (ms): requests and waits to 60000, background tasks to 300000, Redis eval to 10000; the sub-7500 buckets are identical everywhere, so old quantiles stay comparable. + - The Postgres exporter scrapes **the primary as well as the read-only replica**, so the primary's `pg_stat_activity`, `pg_settings_*` and `pg_stat_bgwriter_*` are in Prometheus even though the SQL datasource cannot reach them (the replica's views describe the replica, not the primary). + - Per-database data from the exporter is **aggregate only**: `pg_settings_*` (the whole server configuration, so autovacuum, checkpoint and memory parameters are readable from Prometheus), `pg_stat_database_*`, `pg_stat_bgwriter_*`, `pg_stat_activity_*`, and replication/lock counts. It publishes **no per-table series** (`pg_stat_user_tables_*` resolves empty), so dead tuples, per-table reloptions and relation sizes are SQL-only questions. + - It publishes **no `pg_stat_progress_*` collector** and `pg_stat_activity_count` carries no `backend_type` label, so a session is only identifiable by its remaining labels: autovacuum workers are the bucket with `usename` and `application_name` empty plus `state="active"`, and `pg_wait_events_connections{wait_event="AutoVacuumMain"}` is the autovacuum launcher alive (absent on the replica). + - A metric the exporter does not publish itself has to be added as a custom query block in its YAML queries file on the exporter host (that is where `pg_wait_events_connections` comes from), and in this build the file is read at startup: restart the exporter after editing. There is no per-metric flag to turn on. +- Logs — **Loki**, uid `h3CGKhEMk`. LogQL, and filter by the structured metadata `logger`/`level` (`{job="penpot"} | logger="..." | level="ERROR"`). Two things to remember when counting from it: the CLIMIT rejections have their own counters (`penpot_rpc_climit_rejected_total`, by `name` and `reason`) and their `hint="rejected"` line goes out at WARN only when something is left queued behind the call (a lone caller that times out is written at TRACE), so counting lines is not counting rejections -- the same goes for `hint="enqueued"` and `hint="acquired"`; and the only signal that exists nowhere else is the profile behind a queue (`logger="app.rpc.climit"`, whose `id` carries the key), chased there on demand. + - The Postgres server also ships its own log (`job="postgresql"`, primary only). Those streams carry just `filename`, `instance`, `job` and `level` — no `environment`/`hostname` — so selectors need `instance=~"$host.$env.*"`. `log_autovacuum_min_duration` is low enough that every autovacuum leaves an `automatic vacuum of table "db.schema.table"` line with pages, tuples, index scans and `elapsed`: a per-table history of **finished** vacuums (retrospective, not a view of what runs now). A day of vacuums is only a couple of hundred lines, so pulling them raw over `query_range` and parsing the table, the pages scanned, `tuples: N removed` and the `removable cutoff` XID age in the client works well — less fighting than making LogQL's `pattern`/`regexp` chew a multi-line message. +- SQL — **Postgres**, uid `y8ea9tfMk`. Panels need `rawQuery: true`, `editorMode: "code"` and `format: "table"`. The `task` table is the only truth about the queue depth. It is a **read-only replica**: it rejects any write, `CREATE TEMP TABLE` included, so queries through it must be plain `SELECT`s. Catalog data (`pg_class`, `pg_partition_tree`, `pg_stats`, and `reloptions`) describes the primary faithfully, because catalogs replicate; the statistics views (`pg_stat_user_tables`, `pg_stat_all_tables`) read as zero there, since statistics are per-instance and are not replicated. It also cuts long-running queries (the MCP path has a short timeout), so bound them (e.g. a list of ids); an empty answer there is not proof that there is no data. - **Postgres (telemetry)**, uid `9YAUuM_Gz`. - `$__rate_interval` and `$__interval` are substituted by Grafana; use them instead of fixed windows in graphs. ## Main dashboard -- `Production status`, uid `penpot-production-status`. Sections: general state and deploy, RPC and CLIMIT, PostgreSQL and pool, Redis/msgbus, tasks, JVM/process. Variables `$env` and `$host` (custom), no auto refresh. +- `Production status`, uid `penpot-production-status`. Sections: general state and deploy, `RPC y CLIMIT`, PostgreSQL and pool, Redis/msgbus, tasks, JVM/process. Variables `$env` and `$host` (custom), no auto refresh. Every section opens collapsed except `RPC y CLIMIT`, which is the one read on a normal day. + - `$host` is a custom multi-variable: its `options` list and its `allValue` regex must both enumerate every host. A host added to `query` but missing from `options`/`allValue` is silently dropped when `All` is selected, so panels that filter with `instance=~"$host..."` stop showing it (panel 104 is the visible symptom). Update all three places together when adding a host. - Per-host investigation dashboards are separate, and the alert rules deeplink into them. +- The PostgreSQL section ends with a block of vacuum panels: two stats (autovacuum workers active on the primary, and the launcher alive), a Loki timeseries of finished vacuums per interval and table, and then the `Vacuum TXID age` table, which is the last panel of the section. The worker stat is an approximation — it counts the `pg_stat_activity` bucket described above, because the exporter publishes no `backend_type` — and the Loki panel is retrospective by construction. A per-table, in-flight view (table, phase, progress) needs a `pg_stat_progress_vacuum` collector added to the exporter itself. ## Conventions @@ -26,6 +33,20 @@ Keep this memory free of internal names: no host names, no deployed versions, no - Cron "did it run" comes from the task metrics, not from the cron registry table (it keeps rows from old versions). - Dashboard thresholds must match the alert rule thresholds. +## Editing dashboards through the API + +- Saving through the API normalizes the dashboard: submit the native form, rows with `panels: []` and their panels top-level with contiguous `y`. A row that has to start collapsed (or open) is sent holding its children in `panels` with `collapsed` set, and the top-level `y` are recomputed by hand: one unit per collapsed row, with the expanded block kept contiguous. Grafana accepts it and returns the same shape. +- A `remove` operation on a panel leaves its `gridPos` hole behind: Grafana does not refill the layout (the UI behaves the same), so a neighbouring panel has to be moved by hand when the gap matters. +- Check `list_dashboard_versions` before writing and prefer `operations` patches: the user edits dashboards by hand and a full-JSON overwrite silently reverts that work. + +## Reading the API by hand + +- The MCP authenticates with a service account token (`GRAFANA_SERVICE_ACCOUNT_TOKEN`, set in the environment of the MCP server), and a service account has no user behind it: a 403 is never the token being bad. On `/api/user` and `/api/user/orgs` it is normal; on a single dashboard (`Access denied to this dashboard`, which is not a 404) it means that identity cannot see it, a permission on that dashboard or its folder. Restarting the MCP or rotating the token changes nothing, and `X-Grafana-Org-Id` does not help when the identity belongs to another organization. +- Listing is reliable, filtering is not: a dashboard search with a text query answers empty in this instance (through the tool and through `/api/search?query=...` alike), and `list_prometheus_metric_names` also lists names that survive only in the retained series of a metric that stopped being published. Use the listing without a query, and `count()` to tell whether a metric is alive today. +- The MCP Prometheus tool can answer empty for every query, `up` included, while the datasource itself is fine: run PromQL by hand with `grafana_api_request` against `/api/datasources/uid//resources/api/v1/query?query=...`. +- `curl` and `python` from the shell cannot reach the Grafana host of the deployment: Cloudflare blocks them (`error code: 1010`, which looks like a 403 and has nothing to do with Grafana). Probe the API through the MCP (`grafana_api_request`), which is what the deployment lets through. +- A Loki query by hand goes through `GET /api/datasources/proxy/uid//loki/api/v1/query_range?query=&start=&end=&step=` (the range comes back as `resultType: matrix`, one series per group). For Loki the `/api/datasources/uid//resources/...` route answers 500, and `/api/ds/query` is not reachable from `grafana_api_request` at all (404), even though that is the path the dashboards use. + ## Gotchas - PromQL literal dots need `\\.`; simpler to use `[.]` or a plain dot. @@ -34,9 +55,5 @@ Keep this memory free of internal names: no host names, no deployed versions, no - Prometheus: `label_replace(, "host", "$1", "instance", "([^.]+)[.][^.]+.*")` plus `legendFormat: "... {{host}}"`. - Loki metric query: `| label_format host="{{ regexReplaceAll "[.][^.]*[.][^.]*[.][^.]*$" .instance "" }}"` plus `legendFormat: "{{host}}"`. Quotes inside the template must be escaped as `\"`, or LogQL closes the string early (`not a valid duration string`). - Tables from a Prometheus raw selector carry a `__name__` column; hide it with `organize`. -- Saving through the API normalizes the dashboard (rows may be flattened, `y` recomputed): submit the native form (rows with `panels: []`, panels top-level, contiguous `y`). - Panel images cannot be rendered (no image renderer installed) and there is no browser here: verify a dashboard by running its queries with the variables substituted. - Signals that exist only as logs are counted with Loki, and the count is then cross-checked against the nginx access log for the same window and the same status code (`job="frontend"`, e.g. a `429`): two independent sources must agree before a number is trusted. -- The app writes its own logs at WARN only when something is left queued behind the call and at TRACE otherwise, so production Loki holds just the under-pressure lines: a count of `hint="enqueued"` or `hint="acquired"` is not the number of calls, and neither is a count of `hint="rejected"` lines, because a rejection that leaves the queue empty (a lone caller that times out) is written at TRACE. The rejection counters are the real number. -- The MCP Prometheus tool can answer empty for every query, `up` included, while the datasource itself is fine: an empty result from it is not proof that a panel has no data, and blaming the datasource for it is wrong. Run PromQL by hand with `grafana_api_request` against `/api/datasources/uid//resources/api/v1/query?query=...` (or `POST /api/ds/query`, the path dashboards use), and check when a series is expected but the tool says nothing. -- **Check `list_dashboard_versions` before writing.** The user edits dashboards by hand and a full-JSON overwrite silently reverts that work; prefer `operations` patches per panel. diff --git a/.serena/memories/render-wasm/core.md b/.serena/memories/render-wasm/core.md index c5d664983a..c3b8dd1db7 100644 --- a/.serena/memories/render-wasm/core.md +++ b/.serena/memories/render-wasm/core.md @@ -25,6 +25,7 @@ - The lock covers setup, Cargo build, and artifact copy. The watch process releases it while waiting for changes. The operating system releases it on success, error, or signal; the file remains. - The lock only coordinates repository scripts. Manual Cargo commands do not take it. - Set `RENDER_WASM_LOCK_FILE` to the same path only when separate checkouts intentionally share a `CARGO_TARGET_DIR`. +- Build output is gitignored and stays untracked everywhere it lands: `frontend/resources/public/js/render-wasm.{js,wasm}` and `frontend/src/app/render_wasm/api/shared.js` from `./build`, plus `exporter/resources/wasm/render-wasm.{js,wasm}` and `exporter/src/app/wasm/shared.js`. Never commit generated JS/WASM; rebuild instead. ## Commands diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md index f40bddadf4..9c53875672 100644 --- a/.serena/memories/scripts/gh.md +++ b/.serena/memories/scripts/gh.md @@ -119,6 +119,7 @@ python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 ## Key principles +- `issues --compare` collects every `#NNNN` token in the file regardless of its link target, so a same-numbered reference to another tracker (e.g. `[Taiga #12312](...)`) or an inline prose number falsely marks that GitHub issue as already listed; verify against `changelog.py cross-ref` when a number collides. - Changelog-specific checks (merge-status verification, milestone-vs-changelog cross-reference, anomaly report) live in `scripts/changelog.py`, which calls this script via subprocess — see `mem:scripts/changelog`. - All output is JSON — pipe into `jq` or other tools for further processing. diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index d632c9a471..cf08358aaf 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -2,6 +2,12 @@ Commit only on explicit request. Before commit: `git status`; exclude unrelated user changes. +**Never stage a plan file.** A plan is session bookkeeping, not part of the +change: the commit carries the code and the tests. The `implement-plan` flow +still closes the plan (`Status: done` plus one `Review Log` line with the issue +URL); that is not a file to ship, and its absence from the commit is correct, +not a missing piece. + Do not guess or hallucinate git author information (Name or Email). Never include the `--author` flag in git commands unless specifically instructed by the user for a unique case; assume the local environment is already configured. Allow git commit to @@ -11,12 +17,11 @@ automatically pull the identity from the local git config `user.name` and `user. ## Message Format ``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +:emoji: Subject line (imperative, capitalized, no period, <=80 chars) Body explaining what changed and why. -Wrap lines at 76 characters — git log adds a -four-space indent, so 76 + 4 fits an 80-column -terminal. Keep each line concise. +Wrap lines at 80 characters. +Keep each line concise. AI-assisted-by: model-name ``` @@ -26,19 +31,22 @@ AI-assisted-by: model-name These rules are not advisory. Do not commit until every one holds. A commit that breaks them is wrong, even if the code is right. -- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do - not eyeball it. This is the rule most often skipped. Rationale: `git log` - indents the body four spaces, so 76 + 4 fits an 80-column terminal. -- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period. +- **Body lines MUST wrap at 80 characters or fewer.** Measure every line; do + not eyeball it. This is the rule most often skipped. +- **Subject MUST be ≤80 chars**, imperative, capitalized, no trailing period. + Merge/Revert/Reapply commits skip the length check: git prepends those + prefixes to an existing subject. - **MUST be a blank line** between subject and body. - **MUST run `scripts/check-commit` and get exit code 0 before finishing.** It mechanically validates the rules above; a failing run is a blocker. - It checks `HEAD` by default: `./scripts/check-commit` - For another commit: `./scripts/check-commit -c ` -- **NEVER** hand-wave the body as "one long line". If a line exceeds 76, +- **NEVER** hand-wave the body as "one long line". If a line exceeds 80, break it at a space. +- **NEVER add a `Co-authored-by:` trailer.** It is forbidden; `scripts/check-commit` fails the commit when any line matches it (case-insensitive). +- CI runs `scripts/check-commit` on every new commit (`mem:workflow/creating-commits` is enforced by `.github/workflows/commit-checker.yml`), so a forbidden trailer fails the build. - Exceptions inside the body (do not wrap these): `Signed-off-by:`, - `Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL. + `AI-assisted-by:` trailers, and lines carrying a URL. **AI-assisted-by trailer rules:** - Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash` diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index f9b41275ef..4d9d0fe8d4 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -27,7 +27,7 @@ Always add the PR to the Main project (`--project "Main"`) unless the user expli PR titles follow commit title conventions: ``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +:emoji: Subject line (imperative, capitalized, no period, <=80 chars) ``` See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR title as the final commit subject, so title format matters. diff --git a/.serena/memories/workflow/updating-node.md b/.serena/memories/workflow/updating-node.md new file mode 100644 index 0000000000..a1359bb93f --- /dev/null +++ b/.serena/memories/workflow/updating-node.md @@ -0,0 +1,26 @@ +# Updating Node Across the Toolchain + +No single source of truth: nothing reads `.nvmrc` at build time, and the version is repeated in a different shape per file. Six sites move together, and the image ones carry a digest beside the tag. + +## Pin sites + +- `.nvmrc` → `v`, with the leading `v`. +- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two. +- `docker/images/Dockerfile.mcp` → two stages: `FROM dhi.io/node:-debian13-dev AS build` and `FROM dhi.io/node:-debian13 AS image`. The release stage carries no `-dev`. +- `docker/images/Dockerfile.media-processor` and `docker/images/Dockerfile.exporter` → single `FROM dhi.io/node:-debian13-dev`. +- `.github/workflows/plugins-deploy-api-doc.yml` and `.github/workflows/plugins-deploy-styles-doc.yml` → `runtime: node@` in the `pnpm/setup` step. Its `working-directory: plugins` makes pnpm's own version come from the manifest, so this pin is the Node one; keep it equal to `.nvmrc` minus the `v`. +- DHI tags take the bare version; only the devenv `NODE_VERSION` carries the `v`. +- Every `dhi.io` base image in the repo (the node ones above, plus `debian-base` and `nginx`) is pinned by digest: `FROM dhi.io/:@sha256:`. Tag and digest are a pair; Renovate's `Update DHI base images` commits rewrite both in one go, so a hand-made tag bump that leaves the digest behind keeps the old image. + +## Gotchas + +- DHI tags are exact-version and lag the upstream release, and dhi.io answers 401 to anonymous requests, so a tag that does not exist yet can only be caught by a real build. `build-docker.yml` and `build-docker-devenv.yml` log into dhi.io, so the failure surfaces in CI, not locally. +- Node 24.x is the Krypton LTS line: take the newest patch of that major unless a major bump is the point. +- Renovate also opens these bumps, on `renovate-*` branches and with its config living outside this repo; a manual bump and Renovate's must not diverge on any of the six sites. +- A backport merge can deliver a newer Node into `docker/` and the plugins-deploy workflows while main's `.nvmrc` stays behind, because main receives partial backports of `.github/` and `docker/` from develop. Merging main into staging therefore conflicts on the pin lines: take main's set and finish the bump (`.nvmrc` plus the `packageManager` fields via `mem:workflow/updating-pnpm`), or the six sites drift. +- Both workflows must stay valid YAML. The `python3` in the devenv has no `yaml` module, so validate with `uv run --with pyyaml python3 -c ...` instead of a bare import. + +## Verification + +- `rg ''` finds nothing outside `CHANGES.md` and untracked scratch files (a repo-wide grep without path filters hits multi-megabyte ClojureScript analyzer caches: always scope it). Grep `dhi\.io/node` as well: a line whose tag moved but whose digest did not still pulls the old image. +- After a devenv rebuild, `node --version` matches `.nvmrc` and `pnpm --version` matches `PNPM_VERSION` in `docker/devenv/Dockerfile`. diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md index 1bb1596959..c8455ff680 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/`, @@ -33,7 +33,9 @@ file (never pipe tool output through filters). images). Nothing may call corepack: it is gone from Node 25+. pnpm auto-downloads the `packageManager` version on mismatch (`pmOnFail: download`, the default), so drift self-heals; aligned pins - just skip the download. + just skip the download. Inside the devenv that download lands in the + penpot user's `package-manager-store`, which `files/entrypoint.sh` chowns + to penpot on every start (see `mem:devenv/core`). - The pnpm version pin lives in three places that move together: `PNPM_VERSION` (+ arch SHAs) in `docker/devenv/Dockerfile` and in `docker/images/Dockerfile.{media-processor,exporter,mcp}`, and the @@ -49,14 +51,23 @@ file (never pipe tool output through filters). `pnpm view pnpm dist-tags --json` for `latest-12` (latest 12.x). The tag moves over time; always re-check. 2. Bump `PNPM_VERSION` (+ arch SHAs) in `docker/devenv/Dockerfile` and in - `docker/images/Dockerfile.{media-processor,exporter,mcp}`. + `docker/images/Dockerfile.{media-processor,exporter,mcp}`. Those SHAs are + of the **GitHub release tarballs**, one per arch: download + `pnpm-linux-{arm64,x64}.tar.gz` from the `v` release and + `sha256sum` each. The npm `dist.integrity` that the script stamps in step + 3 is a different hash of a different artifact — never reuse it for the + Dockerfiles. 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 `. + instead: `scripts/sync-pnpm-version `. The explicit form works + from a host whose system pnpm is older than the target (the registry + lookup resolves the hash, and the first later `pnpm` call auto-downloads + the target into `package-manager-store`), so a rebuild is only needed for + the in-container `pnpm --version` to match. 4. Run `scripts/sync-pnpm-version --install` (plain `pnpm install` in each of the 11 workspace roots) to refresh lockfile metadata, then fix any failing workspace (see gotchas) and re-run that directory. @@ -76,10 +87,15 @@ file (never pipe tool output through filters). gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up, so a nested one silently forks install and lockfile behavior. Do not reintroduce it. +- `media-processor/target/package.json` is a generated, gitignored copy that + the script also stamps, so stamping and `--check` report 35 files while + only 34 are tracked. A stale copy there makes `--check` fail with nothing + wrong in the repo: delete `media-processor/target` and re-run. - Expect metadata-only lockfile diffs when only the pnpm version moves: the pnpm self-reference entries, plus a new `packageManagerDependencies` section in lockfiles last written by older pnpm. Large diffs mean re-resolution; inspect them before accepting. +- A backport merge of main into staging can bring a newer `PNPM_VERSION` in `docker/` while every `packageManager` field stays behind, since main receives partial `.github/`+`docker/` backports from develop. Resolve those conflicts with main's version and run `scripts/sync-pnpm-version ` in the same merge. ## Verification 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 fa95b4c045..26db47c2c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ - **Read `AGENTS.local.md` after this file whenever it exists.** It carries the developer's steering for this project and overrides this file on prose style and workflow, but not on project policy, where instructions here prevail. -- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and +- **Commit message body lines MUST wrap at ≤80 chars** (subject ≤80 chars, + except `Merge`/`Revert`/`Reapply` which skip the subject length check) and the commit MUST pass `./scripts/check-commit` with exit code 0 before you consider it done. This is mechanically checked — do not eyeball it. - **Read the workflow memory BEFORE the corresponding action**: @@ -31,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` @@ -102,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/CHANGES.md b/CHANGES.md index 2d83b456f3..6da1e66c2d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -129,11 +129,15 @@ - Fix file restore leaving its storage objects marked for deletion [#12278](https://github.com/penpot/penpot/issues/12278) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) - Fix backend workers staying parked forever when nested calls take the same concurrency limits [#12279](https://github.com/penpot/penpot/issues/12279) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) - Fix one profile's activity blocking the audit events of every other profile [#12280](https://github.com/penpot/penpot/issues/12280) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) +- Fix storage garbage collection blocking database vacuum during long deletions [#12396](https://github.com/penpot/penpot/issues/12396) (PR: [#12397](https://github.com/penpot/penpot/pull/12397)) +- Fix saving a version taking seconds and the version quota never counting saved versions [#12404](https://github.com/penpot/penpot/issues/12404) (PR: [#12405](https://github.com/penpot/penpot/pull/12405)) +- Fix file_change accumulating revisions that the objects GC never collects [#12418](https://github.com/penpot/penpot/issues/12418) (PR: [#12419](https://github.com/penpot/penpot/pull/12419)) ### :sparkles: New features & Enhancements - Add a graceful instance drain through the /readyz endpoint [#12281](https://github.com/penpot/penpot/issues/12281) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) - Add an accumulated queue gauge to the CLIMIT metrics [#12282](https://github.com/penpot/penpot/issues/12282) (PR: [#12269](https://github.com/penpot/penpot/pull/12269)) +- Expose the backend log events as a metric by logger and level [#12312](https://github.com/penpot/penpot/issues/12312) (PR: [#12313](https://github.com/penpot/penpot/pull/12313)) ## 2.18.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a11d90851..9b23ae0fa9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,11 @@ If AI-assisted content is used, it must be carefully reviewed and verified by a Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues). Before filing, search existing issues to avoid duplicates. +> **Note:** Every new issue starts under the `needs triage` label until the +> product team reviews it. Issues under `needs triage` or `internal` are not +> open to contributions — do not start work on them. Wait for a maintainer to +> confirm it in the issue itself. + Include the following when possible: 1. Steps to reproduce the error. @@ -119,7 +124,7 @@ commit history — what matters is the [PR title](#title). Pull request titles **must** follow the same convention as commit subjects: ``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) +:emoji: Subject line (imperative, capitalized, no period, <=80 chars) ``` Read [Creating Commits](./.serena/memories/workflow/creating-commits.md) @@ -164,10 +169,15 @@ To save time on both sides, please avoid submitting PRs that: - Submit AI-generated code without human review. - Skip local syntax and formatting checks before submitting. - Skip the [discussion step](#workflow) for non-bug-fix changes. +- Open a PR for an issue under `needs triage` or `internal`. We may close + these PRs without review, without an explanation and without a reply. ### Good first issues We use the `good first issue` label to mark issues appropriate for newcomers. +An issue is open to contributions only after a maintainer says so in the issue +itself. Issues under `needs triage` or `internal` stay closed, however simple +they look. ## Commit Guidelines @@ -210,11 +220,17 @@ Commit messages must follow this format: - Capitalize the first letter of the subject - Add clear and concise description on the body - Do not end the subject with a period -- Keep the subject to **70 characters** or fewer -- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted) +- Keep the subject to **80 characters** or fewer + (`Merge`/`Revert`/`Reapply` commits skip this limit, since git prepends + those prefixes to an existing subject) +- **Wrap body lines at 80 characters or fewer** (trailers and URLs excepted) - Separate the subject from the body with a **blank line** +- Never add a `Co-authored-by` trailer — it is forbidden and fails + `./scripts/check-commit` You can check a commit against these rules with `./scripts/check-commit`. +CI runs the same script on every new commit in +`.github/workflows/commit-checker.yml`, so violations fail the build. ### Examples diff --git a/backend/package.json b/backend/package.json index 06e280f11a..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 61fb053a66..ec457e4c24 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl index 64ddd9d150..1498edaf87 100644 --- a/backend/resources/app/templates/debug.tmpl +++ b/backend/resources/app/templates/debug.tmpl @@ -326,6 +326,7 @@ Debug Main Page PROGRESS CMD BACKEND + REPLICA NAME CREATED ENDED @@ -339,12 +340,13 @@ Debug Main Page {{job.done}} / {{job.total}} {{job.cmd}} {{job.backend}} + {{job.replica}} {{job.name}} {{job.created-at}} {{job.ended-at}} {% empty %} - No export jobs. + No export jobs. {% endfor %} diff --git a/backend/scripts/_env b/backend/scripts/_env index df51980474..06e3c3206e 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -69,7 +69,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/scripts/decode-file-data b/backend/scripts/decode-file-data new file mode 100755 index 0000000000..04c84ccd73 --- /dev/null +++ b/backend/scripts/decode-file-data @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Decode a file_data blob (app.util.blob) to JSON for inspection with jq. +# +# Offline use (no DB): +# backend/scripts/decode-file-data --input /tmp/blob.bin --format summary | jq . +# +# DB use (backend running, via nREPL): +# ./scripts/nrepl-eval.mjs "(require '[app.scripts.decode-file-data :as dfd] :reload)" +# ./scripts/nrepl-eval.mjs '(dfd/get-file-json sys/system #uuid "..." :format :summary)' + +SCRIPT_DIR=$(dirname "$0") + +source "$SCRIPT_DIR/_env" + +if [ -f "$SCRIPT_DIR/_env.local" ]; then + source "$SCRIPT_DIR/_env.local" +fi + +exec clojure -M:dev -m app.scripts.decode-file-data "$@" diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 1cea8af046..be8c9078eb 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 @@ -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/binfile/common.clj b/backend/src/app/binfile/common.clj index c51b4a0351..5474e7ab3f 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -14,6 +14,7 @@ [app.common.files.changes :as cpc] [app.common.files.helpers :as cfh] [app.common.files.migrations :as fmg] + [app.common.files.tokens :as cfo] [app.common.files.validate :as fval] [app.common.logging :as l] [app.common.media :as cm] @@ -957,26 +958,57 @@ colors colors)) +(defn absorb-unlinked-tokens-source + "Make the file its own tokens source when its tokens source is not one + of the given library-ids, as happens when the library was deleted. The + tokens lib of the source is copied while the library still exists, even + if deleted; otherwise the tokens source and status are removed." + [cfg data library-ids] + (let [source-id (cfo/get-tokens-source data)] + (if (and (some? source-id) + (not (contains? library-ids source-id))) + (if-let [library (get-file cfg source-id + :include-deleted? true + :throw-if-not-exists? false)] + (cfo/absorb-tokens-lib data (:data library)) + (dissoc data :tokens-source :tokens-status)) + data))) + (defn embed-assets [cfg data file-id] - (let [library-ids (get-libraries cfg [file-id])] - (reduce (fn [data library-id] - (if-let [library (get-file cfg library-id :include-deleted? true)] - (ctf/absorb-assets data (:data library)) - data)) - data - library-ids))) + (let [library-ids (get-libraries cfg [file-id]) + data (reduce (fn [data library-id] + (if-let [library (get-file cfg library-id :include-deleted? true)] + (cond-> (ctf/absorb-assets data (:data library)) + (= library-id (cfo/get-tokens-source data)) + (cfo/absorb-tokens-lib (:data library))) + data)) + data + library-ids)] + (absorb-unlinked-tokens-source cfg data library-ids))) -(def ^:const import-transaction-timeout-ms - "Ceiling for binfile import transactions (20 minutes). Interpolated - directly into SQL: compile-time constant, never user input." - (* 20 60 1000)) +(def transaction-idle-timeout-ms + "Idle-in-transaction guard for the long binfile transactions: 30 + minutes. The import and the export of a file run inside one + transaction that can go quiet while the ZIP is decoded and the file + data is realized. The longest operation observed in production is + under 8 minutes, so this leaves room for bigger files and still + aborts a connection that is left idle for half an hour." + 1800000) -(defn configure-database-timeouts! +(defn set-transaction-idle-timeout + "Set `idle_in_transaction_session_timeout` for the current + transaction. Must be called inside a transaction: `SET LOCAL` is + scoped to it." [cfg] (let [conn (db/get-connection cfg)] (db/exec-one! conn [(str "SET LOCAL idle_in_transaction_session_timeout = " - import-transaction-timeout-ms)]) + transaction-idle-timeout-ms)]))) + +(defn configure-import-transaction + [cfg] + (set-transaction-idle-timeout cfg) + (let [conn (db/get-connection cfg)] (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"]))) (defn process-file diff --git a/backend/src/app/binfile/jobs.clj b/backend/src/app/binfile/jobs.clj index c947ae858a..950d157220 100644 --- a/backend/src/app/binfile/jobs.clj +++ b/backend/src/app/binfile/jobs.clj @@ -161,7 +161,7 @@ :input input})] (with-progress cfg context #(let [result (case (int version) - 1 {:file-ids (vec (bf.v1/import-files! cfg))} + 1 (bf.v1/import-files! cfg) 3 (bf.v3/import-files! cfg) (ex/raise :type :validation :code :unsupported-version diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index d7468eaa38..649494a2c6 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -455,7 +455,7 @@ (defn- read-import-v1 [{:keys [::db/conn ::bfc/project-id ::bfc/profile-id ::bfc/input] :as cfg}] - (bfc/configure-database-timeouts! cfg) + (bfc/configure-import-transaction cfg) (pu/with-open [input (zstd-input-stream input) input (io/data-input-stream input)] @@ -482,9 +482,9 @@ (bfm/apply-pending-migrations! cfg) ;; Knowing that the ids of the created files are in index, - ;; just lookup them and return it as a set + ;; just lookup them and return them in the same shape as v3. (let [files (-> bfc/*state* deref :files)] - (into #{} (keep #(get-in @bfc/*state* [:index %])) files)))))) + {:file-ids (into [] (keep #(get-in @bfc/*state* [:index %])) files)}))))) (defmethod read-import :v1 [options] diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index f3d648bb8d..e1d79d36a7 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -274,6 +274,7 @@ (let [detach? (= export-type :detach-libraries) embed? (= export-type :merge-libraries)] (db/tx-run! cfg (fn [cfg] + (bfc/set-transaction-idle-timeout cfg) (cond-> (bfc/get-file cfg file-id {:realize? true :include-deleted? true @@ -285,6 +286,10 @@ embed? (update :data #(bfc/embed-assets cfg % file-id)) + (not (or detach? embed?)) + (update :data #(bfc/absorb-unlinked-tokens-source + cfg % (bfc/get-libraries cfg [file-id]))) + :always (bfc/clean-file-features)))))) @@ -843,7 +848,7 @@ {}) (not-empty)))) -(defn- read-file-tokens-lib +(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)]))] (tap-progress cfg {:section :tokens-lib :file-id file-id}) @@ -851,6 +856,19 @@ (decode-tokens-lib) (validate-tokens-lib)))) +(defn- read-file-tokens-lib + "Read the tokens lib of a file once per import. Decoding generates new + set ids, so the tokens lib of a library must be the same instance when + it is imported and when it resolves the tokens status of a file that + uses it as tokens source." + [cfg file-id] + (let [cache (:tokens-libs @bfc/*state*)] + (if (contains? cache file-id) + (get cache file-id) + (let [tokens-lib (read-file-tokens-lib* cfg file-id)] + (vswap! bfc/*state* assoc-in [:tokens-libs file-id] tokens-lib) + tokens-lib)))) + (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)]))] @@ -1217,7 +1235,7 @@ (defn- import-files* [{:keys [::manifest] :as cfg}] - (bfc/configure-database-timeouts! cfg) + (bfc/configure-import-transaction cfg) (vswap! bfc/*state* update :index bfc/update-index (:files manifest) :id) @@ -1302,7 +1320,7 @@ :hint "branch file data is derived from the branch base and the operation log and can't be overwritten by an import" :file-id file-id)) - (bfc/configure-database-timeouts! cfg) + (bfc/configure-import-transaction cfg) (let [ref-file (bfc/get-minimal-file cfg file-id ::db/for-update true) file (first (get manifest :files)) @@ -1383,7 +1401,8 @@ format. There are some options available for customize the output: `::bfc/export-type`: determines how linked libraries are handled. - Valid values: `:include-libraries` (include linked libraries), + Valid values: + `:include-libraries` (include linked libraries), `:merge-libraries` (embed library assets in the file), `:detach-libraries` (treat assets as basic objects), `:link-later` (preserve component metadata for relinking on import)." diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 3c6186db98..cd1e212964 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -231,7 +231,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/http.clj b/backend/src/app/http.clj index 30efa426e4..aa6950fdeb 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -244,6 +244,7 @@ [::mgmt/routes schema:routes] ::session/manager ::setup/props + ::mtx/metrics ::db/pool]) (defmethod ig/assert-key ::router @@ -260,7 +261,7 @@ [mw/auth {:bearer (partial session/decode-token cfg) :cookie (partial session/decode-token cfg) :token (partial actoken/decode-token cfg)}] - [mw/parse-request] + [mw/parse-request (::mtx/metrics cfg)] [mw/errors errors/handle] [mw/restrict-methods]]} diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 778f6a6fd5..d59f2218e8 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -122,7 +122,11 @@ {::yres/status 400 ::yres/body data})) :else - {::yres/status 400 ::yres/body data}))) + (let [explain (ex/explain data)] + {::yres/status 400 + ::yres/body (-> data + (dissoc ::sm/explain) + (cond-> explain (assoc :explain explain)))})))) (defmethod handle-error :assertion [error request parent-cause] diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index 45f8abecca..ad80bfe9c4 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -14,7 +14,9 @@ [app.config :as cf] [app.http :as-alias http] [app.http.errors :as errors] + [app.metrics :as mtx] [app.tokens :as tokens] + [app.util.inet :as inet] [app.util.pointer-map :as pmap] [cuerdas.core :as str] [yetti.adapter :as yt] @@ -44,27 +46,81 @@ (java.io.BufferedReader. (java.io.InputStreamReader. body)))) +(def ^:private not-a-body + "Marker returned by `read-body-params` when the `content-type` header names + neither transit nor json; the request is left untouched. + + A fresh object and not a keyword: a keyword is a legal value for a decoded + body, so identity is the only safe comparison." + (Object.)) + +(defn- raise-malformed-body + "Signals a body that the parser could not read. + + A failure while reading the body means the body does not match what the + client declared, so it is a client error: the exception class says nothing + here, because `clojure.data.json` signals its syntax errors with a bare + `java.lang.Exception`." + [request metrics content-type cause] + ;; A single line, without the stack trace: the trace of a parse failure + ;; hides the fields (path, peer address, content-type) that identify the + ;; client sending the broken body. + (l/wrn :hint "malformed request body" + :path (yreq/path request) + :remote-addr (inet/parse-request request) + :content-type content-type + :error (ex/get-hint cause)) + + (mtx/run! metrics + :id :http-malformed-requests-total + :inc 1) + + (ex/raise :type :validation + :code :malformed-json + :hint "invalid JSON in request body" + :cause cause)) + +(defn- read-body-params + "Reads the request body according to the `content-type` header. + + `RequestTooBigException` keeps its own code (413) and a structured error + raised from deeper (an `ex-info`) travels untouched; anything else is a + malformed body." + [request metrics content-type] + (try + (cond + (str/starts-with? content-type "application/transit+json") + (with-open [^InputStream is (yreq/body request)] + (t/read! (t/reader is))) + + (str/starts-with? content-type "application/json") + (with-open [reader (get-reader request)] + (json/read reader :key-fn json/read-kebab-key)) + + :else + not-a-body) + + (catch RequestTooBigException cause + (ex/raise :type :validation + :code :request-body-too-large + :hint "request body exceeds size limit" + :cause cause)) + + (catch Throwable cause + (if (ex/error? cause) + (throw cause) + (raise-malformed-body request metrics content-type cause))))) + (defn wrap-parse-request - [handler] + [handler metrics] (letfn [(process-request [request] - (let [header (yreq/get-header request "content-type")] - (cond - (str/starts-with? header "application/transit+json") - (with-open [^InputStream is (yreq/body request)] - (let [params (t/read! (t/reader is))] - (-> request - (assoc :body-params params) - (update :params merge params)))) - - (str/starts-with? header "application/json") - (with-open [reader (get-reader request)] - (let [params (json/read reader :key-fn json/read-kebab-key)] - (-> request - (assoc :body-params params) - (update :params merge params)))) - - :else - request))) + (let [header (yreq/get-header request "content-type") + params (read-body-params request metrics header)] + (if (identical? params not-a-body) + request + (-> request + (assoc :body-params params) + (update :params merge params))))) ;; The specific-exception branches below (IAE, ;; RequestTooBigException, EOFException) raise with @@ -107,10 +163,14 @@ (fn [request] (if (= (yreq/method request) :post) - (try - (-> request process-request handler) - (catch Throwable cause - (handle-error cause request))) + ;; The body is read outside the `try`: a failure there is always + ;; raised already classified by `read-body-params`, and goes out as a + ;; throw like the rest of the specific-exception branches above. + (let [request (process-request request)] + (try + (handler request) + (catch Throwable cause + (handle-error cause request)))) (handler request))))) (def parse-request 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/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/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 7a7d95511a..430d369343 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.consumer-task :as consumer] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] @@ -235,12 +236,19 @@ (defn- append-audit-entry [cfg params] (let [params (-> params - (assoc :id (uuid/next)) + ;; Keep a pre-assigned id (process-event assigns one so + ;; the webhook task can reference the stored row). + (update :id #(or % (uuid/next))) (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 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. + (consumer/queue-pendings cfg [(:id row)]) + row)) (def ^:private xf:filter-telemetry-props "Transducer that keeps only map entries whose values are UUIDs, @@ -254,67 +262,75 @@ (defn- process-event [cfg event] - (when (contains? cf/flags :audit-log-logger) - (l/log! ::l/logger "app.audit" - ::l/level :info - :profile-id (str (:profile-id event)) - :ip-addr (str (:ip-addr event)) - :type (:type event) - :name (:name event) - :props (json/encode (:props event) :key-fn json/write-camel-key) - :context (json/encode (:context event) :key-fn json/write-camel-key))) + ;; Assign the row id up-front so every sink below shares it: the + ;; stored row and the `:process-webhook-event` task params. The + ;; webhook trigger row links back to its origin event through props + ;; `:event-id`, which used to land nil because the id was generated + ;; inside `append-audit-entry` without reaching the worker params. + (let [event (assoc event :id (or (:id event) (uuid/next)))] + (when (contains? cf/flags :audit-log-logger) + (l/log! ::l/logger "app.audit" + ::l/level :info + :profile-id (str (:profile-id event)) + :ip-addr (str (:ip-addr event)) + :type (:type event) + :name (:name event) + :props (json/encode (:props event) :key-fn json/write-camel-key) + :context (json/encode (:context event) :key-fn json/write-camel-key))) - (when (contains? cf/flags :audit-log) - (append-audit-entry cfg event)) + (when (contains? cf/flags :audit-log) + (append-audit-entry cfg event)) - (when (contains? cf/flags :telemetry) - ;; NOTE: when both audit-log and telemetry are enabled, events are stored - ;; twice: once with full details (above) and once stripped of props and - ;; ip-addr, tagged with source="telemetry" so the telemetry task can - ;; collect and ship them. The profile-id is preserved (UUIDs are already - ;; anonymous random identifiers). Only a safe subset of context fields - ;; is kept: initiator, version, client-version and client-user-agent. - ;; Timestamps are truncated to day precision to avoid leaking exact event - ;; timing. - (let [event (-> event - (filter-telemetry-props) - (filter-telemetry-context) - (update :created-at ct/truncate :days) - (update :tracked-at ct/truncate :days) - (assoc :source "telemetry:backend") - (assoc :ip-addr "0.0.0.0"))] - (append-audit-entry cfg event))) + (when (contains? cf/flags :telemetry) + ;; NOTE: when both audit-log and telemetry are enabled, events are stored + ;; twice: once with full details (above) and once stripped of props and + ;; ip-addr, tagged with source="telemetry" so the telemetry task can + ;; collect and ship them. The profile-id is preserved (UUIDs are already + ;; anonymous random identifiers). Only a safe subset of context fields + ;; is kept: initiator, version, client-version and client-user-agent. + ;; Timestamps are truncated to day precision to avoid leaking exact event + ;; timing. The shadow row gets its own id: reusing the full row id + ;; would collide on the primary key. + (let [event (-> event + (assoc :id (uuid/next)) + (filter-telemetry-props) + (filter-telemetry-context) + (update :created-at ct/truncate :days) + (update :tracked-at ct/truncate :days) + (assoc :source "telemetry:backend") + (assoc :ip-addr "0.0.0.0"))] + (append-audit-entry cfg event))) - (when (and (contains? cf/flags :webhooks) - (::webhooks/event? event)) - (let [batch-key (::webhooks/batch-key event) - batch-timeout (::webhooks/batch-timeout event) - label (dm/str "rpc:" (:name event)) - label (cond - (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) - (string? batch-key) (dm/str label ":" batch-key) - :else label) - dedupe? (boolean (and batch-key batch-timeout))] + (when (and (contains? cf/flags :webhooks) + (::webhooks/event? event)) + (let [batch-key (::webhooks/batch-key event) + batch-timeout (::webhooks/batch-timeout event) + label (dm/str "rpc:" (:name event)) + label (cond + (ifn? batch-key) (dm/str label ":" (batch-key (::rpc/params event))) + (string? batch-key) (dm/str label ":" batch-key) + :else label) + dedupe? (boolean (and batch-key batch-timeout))] - (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) + (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* "A public API, lower-level than submit, assumes all required fields are filled" @@ -402,6 +418,47 @@ coerced)) +(def ^:private sql:team-id-by-project + "SELECT id FROM team + WHERE id = (SELECT team_id FROM project WHERE id = ?)") + +(def ^:private sql:team-id-by-file + "SELECT t.id FROM team AS t + JOIN project AS p ON (p.team_id = t.id) + JOIN file AS f ON (f.project_id = p.id) + WHERE f.id = ?") + +(def ^:private read-event-names + "Read commands excluded from team-id resolution on top of the `get-*` + prefix: their events carry no consumer state, so the lookup would only + tax the read path." + #{"has-file-libraries"}) + +(defn- read-event? + [event-name] + (or (str/starts-with? event-name "get-") + (contains? read-event-names event-name))) + +(defn- enrich-team-id + "Adds the team id to the event props when it is missing and can be resolved + locally, so consumers do not have to join production tables. Only events that + already carry a project or file id are considered: read commands put the + entity id under `:id`, and resolving those would add a query to every read." + [cfg event-name props] + (if (or (:team-id props) + (read-event? event-name) + (and (nil? (:project-id props)) + (nil? (:file-id props)))) + props + (let [project-id (:project-id props) + file-id (:file-id props) + team-id (or (when (some? project-id) + (:id (db/exec-one! cfg [sql:team-id-by-project project-id]))) + (when (some? file-id) + (:id (db/exec-one! cfg [sql:team-id-by-file file-id]))))] + (cond-> props + (some? team-id) (assoc :team-id team-id))))) + (defn prepare-rpc-event [cfg mdata params result] (let [resultm (meta result) @@ -416,25 +473,32 @@ (::rpc/profile-id params) uuid/zero) + module (get cfg ::rpc/module) + event-name (or (::name resultm) + (let [sname (::sv/name mdata)] + (if (not= module "main") + (str module "-" sname) + sname))) + props (-> (or (::replace-props resultm) (merge params (::props resultm))) (clean-props)) + ;; Resolve the team locally when the event carries a project or file + ;; id, so the consumers do not need to join production tables. + ;; Reads are excluded: their events carry no consumer state. + props (enrich-team-id cfg event-name props) + context (-> (::context resultm) (merge (prepare-context-from-request request)) (assoc :request-id (::rpc/request-id params)) (d/without-nils)) - ip-addr (inet/parse-request request) - module (get cfg ::rpc/module)] + ip-addr (inet/parse-request request)] {:type (or (::type resultm) (::rpc/type cfg)) - :name (or (::name resultm) - (let [sname (::sv/name mdata)] - (if (not= module "main") - (str module "-" sname) - sname))) + :name event-name :profile-id profile-id :ip-addr ip-addr @@ -491,12 +555,41 @@ (d/without-nils))] (submit* cfg event))) +(defn submit-create-event + "Emits a `create-*` audit event for an entity created as a side effect of a + bulk operation (import, duplicate, clone). Those entities get no event from + their own command, so a local projection cannot key them otherwise. The + event is built by hand and does not pass through `prepare-rpc-event`, so its + props are passed as-is minus nils." + [cfg params event-name props] + (when (contains? cf/flags :audit-log) + (submit cfg + (-> (event-from-rpc-params params) + (assoc :name event-name) + (assoc :props (d/without-nils props)))))) + +(def ^:private sql:get-file-event-columns + "SELECT id, name, project_id, created_at, modified_at, revn + FROM file WHERE id = ?;") + +(defn submit-file-created-event + "Emit a `create-file` event for a file born inside a bulk operation, + with the same prop shape as the `create-file` command event. Only + the file id is needed: the light columns are read back (no data + blob), so projections that key on name, revn or timestamps work. + `extra` carries what the row lacks (the team id)." + [cfg params file-id extra] + (when-let [row (db/exec-one! cfg [sql:get-file-event-columns file-id])] + (submit-create-event cfg params "create-file" (merge row extra)))) + (defn insert "Submit audit event to the collector, intended to be used only from command line helpers because this skips all webhooks and telemetry 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) @@ -507,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 deleted file mode 100644 index 1264882eaa..0000000000 --- a/backend/src/app/loggers/audit/archive_task.clj +++ /dev/null @@ -1,308 +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.archive-task - (:require - [app.common.exceptions :as ex] - [app.common.logging :as l] - [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] - [app.jobs :as jobs] - [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. - -(defn- decode-row - [{:keys [props ip-addr context] :as row}] - (cond-> row - (db/pgobject? props) - (assoc :props (db/decode-transit-pgobject props)) - - (db/pgobject? context) - (assoc :context (db/decode-transit-pgobject context)) - - (db/pgobject? ip-addr "inet") - (assoc :ip-addr (db/decode-inet ip-addr)))) - -(def ^:private event-keys - [:type - :name - :source - :created-at - :tracked-at - :profile-id - :ip-addr - :props - :context]) - -(defn- row->event - [row] - (select-keys row event-keys)) - -(defn- send! - [{: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})] - - (if (= (:status resp) 204) - true - (do - (l/error :hint "unable to archive events" - :resp-status (:status resp) - :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" - "create-export-binfile-job" - "create-file" - "create-organization" - "create-organization-attribute" - "create-organization-invitation" - "create-project" - "create-team" - "create-team-access-request" - "create-team-invitation" - "create-team-invitations" - "create-webhook" - "delete-file" - "delete-font" - "delete-organization" - "delete-organization-member" - "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" - "success-activate-organization-sso" - "success-apply-organization-sso-changes" - "update-organization-invitation" - "update-organization-permissions" - "update-team-invitation" - "update-team-invitation-role" - "update-team-member-role" - "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 (enrich-events-for-nitrate cfg 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 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") - -(defn- get-event-rows - [{:keys [::db/conn] :as cfg}] - (->> (db/exec! conn [sql:get-audit-log-chunk]) - (not-empty))) - -(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)) - - (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)))))))) - -(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]] - [:enabled {:optional true} :boolean] - [:uri {:optional true} ::sm/uri]]) - -(defmethod ig/init-key ::job-def - [_ cfg] - {::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)}) - -(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/consumer_task.clj b/backend/src/app/loggers/audit/consumer_task.clj new file mode 100644 index 0000000000..7270cfae94 --- /dev/null +++ b/backend/src/app/loggers/audit/consumer_task.clj @@ -0,0 +1,685 @@ +;; 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.consumer-task + (:require + [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 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-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. + +;; 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 consumer-nexus 1) +(def consumer-admin-console 2) + +(def ^:private consumers + [{:id consumer-nexus :flag :nexus :label "nexus"} + {:id consumer-admin-console :flag :admin-console :label "admin-console"}]) + +(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-consumers + "Consumers allowed to work right now, from the send flags. Public: + producers queue every record for exactly these consumers." + [] + (into [] xf:enabled-consumers consumers)) + +(defn queue-pendings + "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 [consumers (enabled-consumers)] + (when (and (seq ids) (seq consumers)) + (db/insert-many! cfg :audit-log-pending + [:audit-log-id :consumer] + (for [id ids + consumer consumers] + [id consumer]) + {::sql/on-conflict-do-nothing true})))) + +(def ^:private consumer-labels + (into {} (map (juxt :id :label)) consumers)) + +(defn- consumer-label + [consumer] + (get consumer-labels consumer)) + +(defn- decode-row + [{:keys [props ip-addr context] :as row}] + (cond-> row + (db/pgobject? props) + (assoc :props (db/decode-transit-pgobject props)) + + (db/pgobject? context) + (assoc :context (db/decode-transit-pgobject context)) + + (db/pgobject? ip-addr "inet") + (assoc :ip-addr (db/decode-inet ip-addr)))) + +(def ^:private event-keys + [:type + :name + :source + :created-at + :tracked-at + :profile-id + :ip-addr + :props + :context]) + +(defn- row->event + [row] + (select-keys row event-keys)) + +(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 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. +(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.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 consumer = ?::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} consumer] + (->> (db/exec! conn [sql:pending-chunk consumer]) + (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]} consumer rows] + (let [ids (db/create-array conn "uuid" (map :id rows))] + (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 + 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 [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) + n + (throw (ex/raise :type :internal + :code :consumer-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-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" + "create-organization" + "create-organization-attribute" + "create-organization-invitation" + "create-project" + "create-team" + "create-team-access-request" + "create-team-invitation" + "create-team-invitations" + "create-webhook" + "delete-file" + "delete-font" + "delete-organization" + "delete-organization-member" + "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" + "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" + "update-organization-permissions" + "update-team-invitation" + "update-team-invitation-role" + "update-team-member-role" + "update-team-photo" + "verify-token"}) + +;; 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 + :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- 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) + (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-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, fullname FROM profile WHERE id = ANY(?)" arr])] + (into {} (map (juxt :id identity) 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 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))] + (: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 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 (into (collect-profile-ids-needing-email events) + (collect-actor-ids-needing-profile events)) + team-names (load-team-names conn team-ids) + profiles (load-profiles conn profile-ids)] + (mapv (fn [event] + (-> event + (maybe-assoc-team-name team-names) + (maybe-assoc-emails profiles) + (maybe-assoc-actor-profile profiles))) + events))) + +(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] + (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 "consumer events chunk ready" + :consumer "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 "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 "consumer events chunk failed" + :consumer "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- 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 + 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 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 "consumer events chunk" + :consumer "nexus" + :events n) + + (complete-batch cfg consumer-nexus rows) + {:sent n}))) + +(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 + consumer-nexus-chunk." + [cfg] + (db/exec! cfg ["SET LOCAL lock_timeout = '30s'"]) + (when-let [rows (get-pending-rows cfg consumer-admin-console)] + (let [decoded (mapv decode-row rows) + _ (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 consumer is done. + (complete-batch cfg consumer-admin-console rows) + admin-console-res))) + +(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." + [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}))) + +(defn- sql:reap-pendings + [consumer] + (str "DELETE FROM audit_log_pending" + " WHERE consumer = " consumer + " AND audit_log_id IN (SELECT audit_log_id FROM audit_log_pending" + " WHERE consumer = " consumer + " ORDER BY audit_log_id LIMIT 5000)")) + +(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)") + +(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 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 consumer, or swept unsent when nothing waits for them." + [cfg] + (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-consumer-discarded ["reaped"] total)) + total)) + +(defn- sweep-orphans + "Delete records nobody waits for anymore: rows born while no + 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-consumer-discarded ["swept"] total)) + total)) + +(declare execute-audit-log-consumer-nexus) +(declare execute-audit-log-consumer-admin-console) + +(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. + 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]]) + +(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 + schema." + [:map {:closed true}]) + +(defmethod ig/assert-key ::nexus-job-def + [_ cfg] + (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 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-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] + ;; 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 consumer job initialized" + :consumer "admin-console" + :uri-host (some-> (cf/get :admin-console-uri) u/uri u/get-domain)) + {::jobs/name :audit-log-consumer-admin-console + ::jobs/schema schema:audit-log-consumer-admin-console-params + ::jobs/handler + (fn [_context 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-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 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 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 + ;; 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-consumer cfg send-chunk) + (catch Throwable cause + (let [n (:events (ex-data cause))] + (when (and (integer? n) (pos? n)) + (record-count metrics :audit-consumer-events [label "failed"] n))) + (throw cause))) + _ (let [{:keys [sent skipped]} drained] + (when (pos? sent) + (record-count metrics :audit-consumer-events [label "archived"] sent)) + (when (pos? skipped) + (record-count metrics :audit-consumer-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" + :consumer label) + (l/dbg :hint "events archived" + :consumer label + :elapsed elapsed + :sent sent + :skipped skipped + :chunks chunks + :reaped reaped + :swept swept)))) + +(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 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) + cfg (assoc cfg ::uri uri)] + (when-not uri + (ex/raise :type :internal + :code :task-not-configured + :hint "consumer task not configured, missing uri")) + (run-single-consumer cfg consumer-nexus consumer-nexus-chunk (ct/tpoint))))) + +(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 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/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/loggers/metrics.clj b/backend/src/app/loggers/metrics.clj new file mode 100644 index 0000000000..07c97debd1 --- /dev/null +++ b/backend/src/app/loggers/metrics.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 Sucursal en España SL + +(ns app.loggers.metrics + "A reporter that counts the log records of this application, by logger and + level, and publishes them as a counter." + (:require + [app.common.logging :as l] + [app.metrics :as mtx] + [integrant.core :as ig])) + +(def ^:private counted-levels + "The levels worth counting: `info` is emitted often and says nothing about + the health of the application." + #{:warn :error :fatal}) + +(defmethod ig/assert-key ::reporter + [_ params] + (assert (mtx/metrics? (::mtx/metrics params)) "expect valid metrics instance")) + +(defmethod ig/init-key ::reporter + [_ {::mtx/keys [metrics]}] + (add-watch l/log-record ::reporter + (fn [_ _ _ record] + ;; This runs inside the logging path: the watcher fires from + ;; the `swap!` of the log record, so an exception here would + ;; reach whoever was logging on the synchronous path, and on + ;; the async one it would die in the executor's uncaught + ;; handler, taking that log line out of Loki. Swallowed on + ;; purpose either way. + (try + (let [level (::l/level record)] + (when (contains? counted-levels level) + (mtx/run! metrics + :id :log-events + :inc 1 + :labels (into-array String [(::l/logger record) (name level)])))) + (catch Throwable _ nil)))) + {::mtx/metrics metrics}) + +(defmethod ig/halt-key! ::reporter + [_ _] + (remove-watch l/log-record ::reporter)) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d5aa7538c0..2f119a769f 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -28,7 +28,7 @@ [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 :as mtx] [app.metrics.definition :as-alias mdef] [app.msgbus :as-alias mbus] [app.redis :as-alias rds] @@ -54,7 +54,13 @@ (:gen-class)) (def default-metrics - {:update-file-changes + {:log-events + {::mdef/name "penpot_log_events_total" + ::mdef/help "Log records at WARN, ERROR and FATAL emitted by the application, by logger and level." + ::mdef/labels ["logger" "level"] + ::mdef/type :counter} + + :update-file-changes {::mdef/name "penpot_rpc_update_file_changes_total" ::mdef/help "A total number of changes submitted to update-file." ::mdef/type :counter} @@ -64,16 +70,25 @@ ::mdef/help "A total number of bytes processed by update-file." ::mdef/type :counter} + :update-file-validation-timing + {::mdef/name "penpot_rpc_update_file_validation_timing" + ::mdef/help "update-file validation step timings (milliseconds)." + ::mdef/labels ["step"] + ::mdef/buckets mtx/default-request-histogram-buckets + ::mdef/type :histogram} + :rpc-main-timing {::mdef/name "penpot_rpc_main_timing" ::mdef/help "RPC command method call timing for main" ::mdef/labels ["name"] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :rpc-management-timing {::mdef/name "penpot_rpc_management_timing" ::mdef/help "RPC command method call timing for management." ::mdef/labels ["name"] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :websocket-active-connections @@ -106,12 +121,20 @@ ;; panel or alert breaks. ::mdef/help "Deprecated: use penpot_jobs_execution_ms, which adds a queue label." ::mdef/labels ["name"] + ::mdef/buckets mtx/default-tasks-histogram-buckets ::mdef/type :histogram} + :storage-gc-deleted-objects + {::mdef/name "penpot_storage_gc_deleted_objects_total" + ::mdef/help "Storage objects permanently deleted by storage-gc-deleted, by backend." + ::mdef/labels ["backend"] + ::mdef/type :counter} + :redis-eval-timing {::mdef/name "penpot_redis_eval_timing" ::mdef/help "Redis EVAL commands execution timings (ms)" ::mdef/labels ["name"] + ::mdef/buckets mtx/default-redis-histogram-buckets ::mdef/type :histogram} :rpc-climit-waiting @@ -130,6 +153,7 @@ {::mdef/name "penpot_rpc_climit_timing" ::mdef/help "Summary of the time between queuing and executing on the CLIMIT" ::mdef/labels ["name"] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :audit-http-handler-queue-size @@ -148,6 +172,7 @@ {::mdef/name "penpot_audit_http_handler_timing" ::mdef/help "Summary of the time between queuing and executing on the audit log http handler" ::mdef/labels [] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :storage-s3-requests @@ -197,6 +222,7 @@ {::mdef/name "penpot_http_server_dispatch_timing" ::mdef/help "Histogram of dispatch handler" ::mdef/labels [] + ::mdef/buckets mtx/default-request-histogram-buckets ::mdef/type :histogram} :http-worker-queue-size @@ -340,6 +366,48 @@ ::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]} + + :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-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-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} + + :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} + + :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 @@ -460,6 +528,7 @@ ::rpc/routes (ig/ref ::rpc/routes) ::setup/props (ig/ref ::setup/props) ::mtx/routes (ig/ref ::mtx/routes) + ::mtx/metrics (ig/ref ::mtx/metrics) ::oidc/routes (ig/ref ::oidc/routes) ::mgmt/routes (ig/ref ::mgmt/routes) ::http.debug/routes (ig/ref ::http.debug/routes) @@ -543,7 +612,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 consumer-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 @@ -571,25 +641,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-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) @@ -678,15 +748,16 @@ {::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) + :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)} + ::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)} + :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)} ::email/blacklist {} @@ -743,6 +814,9 @@ :app.loggers.database/reporter {::db/pool (ig/ref ::db/pool)} + :app.loggers.metrics/reporter + {::mtx/metrics (ig/ref ::mtx/metrics)} + ::sto/storage {::db/pool (ig/ref ::db/pool) ::mtx/metrics (ig/ref ::mtx/metrics) @@ -804,15 +878,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-consumer-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-consumer-admin-console})]} ::wrk/dispatcher {::rds/client (ig/ref ::rds/client) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index cd24777a3c..673c193e38 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -148,6 +148,25 @@ (number? value) (str value) :else fallback)) +;; Tail for request and wait timings (RPC, CLIMIT waits, audit handler, +;; HTTP dispatch): covers the 30s/60s CLIMIT timeouts while keeping the +;; sub-7.5s resolution of the default buckets untouched. +(def default-request-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500 + 10000 15000 30000 60000]) + +;; Tail for background tasks (GC sweeps, file workers): they run until +;; done with no CLIMIT timeout, bounded in practice by the 300s postgres +;; statement timeout. +(def default-tasks-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500 + 10000 15000 30000 60000 120000 300000]) + +;; Tail for Redis eval: sub-second in practice, one extra bucket covers +;; the pathological (timeout) case without adding idle series. +(def default-redis-histogram-buckets + [1 5 10 25 50 75 100 250 500 750 1000 2500 5000 7500 10000]) + (defmulti run-collector! (fn [mdef _] (::mdef/type mdef))) (defmulti create-collector ::mdef/type) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 4ffc31dadf..72778a3d30 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -526,6 +526,15 @@ {:name "0154-del-legacy-file-object-thumbnail-table" :fn (mg/resource "app/migrations/sql/0154-del-legacy-file-object-thumbnail-table.sql")} + {:name "0155-add-idx-to-file-change" + :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")} + + {:name "0157-add-audit-log-pending-table" + :fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")} + {:name "0800-add-file-branch-table" :fn (mg/resource "app/migrations/sql/0800-add-file-branch-table.sql")} diff --git a/backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql b/backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql new file mode 100644 index 0000000000..28357cc75c --- /dev/null +++ b/backend/src/app/migrations/sql/0155-add-idx-to-file-change.sql @@ -0,0 +1,38 @@ +-- Index for the version (snapshot) quota checks of app.rpc.quotes: +-- sql:get-snapshots-per-file and sql:get-snapshots-per-team count the live +-- versions created by a user, and this partial index answers both counts +-- with an index only scan, without walking the whole file_change table. +-- +-- The predicate must stay identical to the queries: the planner only uses a +-- partial index when the query where clause implies the index predicate. +-- Versions are told apart by label (every snapshot has one); file_change.data +-- is a legacy column, only written by the legacy-db file data backend. + +CREATE INDEX IF NOT EXISTS file_change__user_snapshots__idx + ON file_change (file_id) + WHERE label IS NOT NULL + AND created_by = 'user' + AND deleted_at IS NULL; + +-- NOTE: on instances where file_change is large, run the statement above +-- out of band, before deploying, with CONCURRENTLY instead: this migration +-- runs inside a transaction, and a plain CREATE INDEX locks the table +-- against writes for the whole build (in Penpot every file save writes a row +-- here), while CONCURRENTLY does not. Run it on its own, not inside a +-- transaction block: +-- +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS file_change__user_snapshots__idx +-- ON file_change (file_id) +-- WHERE label IS NOT NULL +-- AND created_by = 'user' +-- AND deleted_at IS NULL; +-- +-- A CONCURRENTLY build interrupted midway leaves the index INVALID: drop it +-- and retry. Verify with: +-- +-- SELECT indexdef FROM pg_indexes +-- WHERE indexname = 'file_change__user_snapshots__idx'; +-- SELECT indisvalid FROM pg_index +-- WHERE indexrelid = 'file_change__user_snapshots__idx'::regclass; +-- +-- Once the index exists, this migration finds it and does nothing. diff --git a/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql b/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql new file mode 100644 index 0000000000..46a7cce54b --- /dev/null +++ b/backend/src/app/migrations/sql/0156-reset-pg-storage-defaults.sql @@ -0,0 +1,131 @@ +-- Reset explicit `STORAGE EXTERNAL` settings back to the PostgreSQL defaults. +-- +-- Those were set when PostgreSQL ran on top of ZFS with transparent +-- compression (see 0041-mod-pg-storage-options.sql and the per-table +-- migrations that followed it): skipping the TOAST compression done by +-- PostgreSQL made sense there. On regular filesystems it only wastes +-- space and I/O bandwidth, so restore the default storage strategy of +-- each column type (EXTENDED for every column below: text, text[], +-- jsonb and bytea). +-- +-- This only touches the catalog, it does not rewrite the tables: +-- already stored TOAST values keep their strategy until the row is +-- rewritten, new writes use the default. +-- +-- NOTE: `SET STORAGE DEFAULT` would express the same thing (it restores +-- the storage mode of the column's data type), but it only exists since +-- PostgreSQL 16 and the project also runs on PostgreSQL 15. Here every +-- column below has a type whose default storage mode is EXTENDED +-- (text, text[], jsonb and bytea), so `SET STORAGE EXTENDED` reaches the +-- same catalog state on any supported version. RESET STORAGE is not +-- valid syntax for a column. + +ALTER TABLE access_token + ALTER COLUMN name SET STORAGE EXTENDED, + ALTER COLUMN perms SET STORAGE EXTENDED, + ALTER COLUMN token SET STORAGE EXTENDED; + +ALTER TABLE audit_log + ALTER COLUMN context SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED, + ALTER COLUMN source SET STORAGE EXTENDED, + ALTER COLUMN type SET STORAGE EXTENDED; + +ALTER TABLE comment + ALTER COLUMN content SET STORAGE EXTENDED; + +ALTER TABLE comment_thread + ALTER COLUMN page_name SET STORAGE EXTENDED, + ALTER COLUMN participants SET STORAGE EXTENDED; + +ALTER TABLE file + ALTER COLUMN data SET STORAGE EXTENDED, + ALTER COLUMN data_backend SET STORAGE EXTENDED, + ALTER COLUMN features SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED; + +ALTER TABLE file_change + ALTER COLUMN changes SET STORAGE EXTENDED, + ALTER COLUMN data SET STORAGE EXTENDED, + ALTER COLUMN features SET STORAGE EXTENDED, + ALTER COLUMN label SET STORAGE EXTENDED; + +ALTER TABLE file_data_fragment + ALTER COLUMN metadata SET STORAGE EXTENDED; + +ALTER TABLE file_media_object + ALTER COLUMN mtype SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED; + +ALTER TABLE file_thumbnail + ALTER COLUMN data SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED; + +ALTER TABLE global_complaint_report + ALTER COLUMN content SET STORAGE EXTENDED, + ALTER COLUMN type SET STORAGE EXTENDED; + +ALTER TABLE profile + ALTER COLUMN auth_backend SET STORAGE EXTENDED, + ALTER COLUMN email SET STORAGE EXTENDED, + ALTER COLUMN fullname SET STORAGE EXTENDED, + ALTER COLUMN lang SET STORAGE EXTENDED, + ALTER COLUMN password SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED, + ALTER COLUMN theme SET STORAGE EXTENDED; + +ALTER TABLE profile_complaint_report + ALTER COLUMN content SET STORAGE EXTENDED, + ALTER COLUMN type SET STORAGE EXTENDED; + +ALTER TABLE project + ALTER COLUMN name SET STORAGE EXTENDED; + +ALTER TABLE scheduled_task + ALTER COLUMN cron_expr SET STORAGE EXTENDED, + ALTER COLUMN id SET STORAGE EXTENDED; + +ALTER TABLE server_error_report + ALTER COLUMN content SET STORAGE EXTENDED; + +ALTER TABLE server_prop + ALTER COLUMN content SET STORAGE EXTENDED, + ALTER COLUMN id SET STORAGE EXTENDED; + +ALTER TABLE storage_object + ALTER COLUMN backend SET STORAGE EXTENDED, + ALTER COLUMN metadata SET STORAGE EXTENDED; + +ALTER TABLE task + ALTER COLUMN error SET STORAGE EXTENDED, + ALTER COLUMN label SET STORAGE EXTENDED, + ALTER COLUMN name SET STORAGE EXTENDED, + ALTER COLUMN props SET STORAGE EXTENDED, + ALTER COLUMN queue SET STORAGE EXTENDED, + ALTER COLUMN status SET STORAGE EXTENDED; + +ALTER TABLE team + ALTER COLUMN name SET STORAGE EXTENDED; + +ALTER TABLE team_font_variant + ALTER COLUMN font_family SET STORAGE EXTENDED, + ALTER COLUMN font_style SET STORAGE EXTENDED; + +ALTER TABLE team_invitation + ALTER COLUMN email_to SET STORAGE EXTENDED, + ALTER COLUMN role SET STORAGE EXTENDED; + +ALTER TABLE usage_quote + ALTER COLUMN target SET STORAGE EXTENDED; + +ALTER TABLE webhook + ALTER COLUMN error_code SET STORAGE EXTENDED, + ALTER COLUMN mtype SET STORAGE EXTENDED, + ALTER COLUMN secret_key SET STORAGE EXTENDED, + ALTER COLUMN uri SET STORAGE EXTENDED; + +ALTER TABLE webhook_delivery + ALTER COLUMN error_code SET STORAGE EXTENDED, + ALTER COLUMN req_data SET STORAGE EXTENDED, + ALTER COLUMN rsp_data SET STORAGE EXTENDED; 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..ce797dd12c --- /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 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 + -- 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, + 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. +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 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 + ON audit_log (created_at ASC, id); 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/src/app/redis.clj b/backend/src/app/redis.clj index c7ea49be53..146e8f54fe 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -419,7 +419,7 @@ (try (-del conn keys) (catch RedisCommandTimeoutException cause - (l/err :hint "timeout on del redis key" :key key :cause cause) + (l/err :hint "timeout on del redis key" :keys keys :cause cause) nil)))) (defn ping diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 0275100605..5f1cb34883 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -126,7 +126,15 @@ (-> (assoc ::auth-type :token) (assoc ::token-perms (set (::actoken/perms request #{}))))) (cond-> key-id - (assoc ::auth-key-id key-id))) + (assoc ::auth-key-id key-id)) + ;; Server-side only: request bodies cannot forge + ;; it (qualified keys are stripped before the + ;; merge). Upload maps are only trusted when they + ;; arrive over multipart. + (assoc ::is-multipart + (str/starts-with? + (str (yreq/get-header request "content-type")) + "multipart/"))) data (with-meta data {::http/request request}) @@ -227,10 +235,23 @@ (fn [cfg params] (let [request-params (-> params meta ::http/request :params decode)] (if (validate request-params) - (let [result (f cfg (merge params (d/without-qualified request-params)))] - (if (instance? clojure.lang.IObj result) - (vary-meta result assoc :encode/json encode) - result)) + (let [merged (merge params (d/without-qualified request-params))] + ;; Upload temp files are created server-side by the multipart + ;; parser, so an upload map smuggled in a transit/JSON body can + ;; point :path at any internal server file. Methods declare + ;; their upload keys with ::multipart-only-params and they are + ;; only accepted when ::is-multipart arrived server-side with + ;; the request (request bodies cannot forge qualified keys). + (when (some #(and (contains? merged %) + (not (::is-multipart merged))) + (::multipart-only-params mdata)) + (ex/raise :type :validation + :code :params-validation + :hint "upload requires multipart")) + (let [result (f cfg merged)] + (if (instance? clojure.lang.IObj result) + (vary-meta result assoc :encode/json encode) + result))) (ex/raise :type :validation :code :params-validation ::sm/explain (explain request-params)))))) @@ -429,7 +450,8 @@ [cfg] (let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing) mods (cond->> (list 'app.rpc.management.exporter - 'app.rpc.management.jobs) + 'app.rpc.management.jobs + 'app.rpc.management.files) (contains? cf/flags :admin-console) (cons 'app.rpc.management.nitrate))] diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 893160a23e..4d5f26a402 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.consumer-task :as consumer] [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 consumer (see consumer-task). + (consumer/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/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 67163c1568..29d3fcdd52 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -16,7 +16,7 @@ [app.config :as cf] [app.db :as db] [app.http.sse :as sse] - [app.loggers.audit :as-alias audit] + [app.loggers.audit :as audit] [app.loggers.webhooks :as-alias webhooks] [app.media.validation :as media.v] [app.rpc :as-alias rpc] @@ -83,7 +83,7 @@ ;; --- Command: import-binfile (defn- import-binfile - [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}] + [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id] :as params}] (let [team (teams/get-team pool :profile-id profile-id @@ -116,6 +116,14 @@ {:id project-id} {::db/return-keys false}) + ;; The imported files get no event from their own command, so emit one per + ;; file inside a single transaction. + (db/tx-run! cfg + (fn [cfg] + (doseq [file-id (:file-ids result)] + (audit/submit-file-created-event cfg params file-id + {:team-id (:id team)})))) + result)) (def ^:private schema:import-binfile @@ -145,6 +153,7 @@ ::webhooks/event? true ::sse/stream? true ::sm/params schema:import-binfile + ::rpc/multipart-only-params [:file] ::climit/id [[:import-binfile/by-profile ::rpc/profile-id] [:import-binfile/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}] diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 459ebbeaa0..fbd460cbb2 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -489,7 +489,7 @@ (let [params (assoc params ::file file) thread (db/tx-run! cfg create-comment-thread params)] - (vary-meta thread assoc ::audit/props thread)))) + (vary-meta thread assoc ::audit/props (assoc thread :team-id team-id))))) (defn- create-comment-thread [{:keys [::db/conn] :as cfg} @@ -653,7 +653,7 @@ (send-comment-emails! cfg profile comment thread file) - (vary-meta comment assoc ::audit/props comment)))) + (vary-meta comment assoc ::audit/props (assoc comment :team-id team-id))))) ;; --- COMMAND: Update Comment diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 50645a4492..3c94d932b1 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -613,11 +613,21 @@ (let [process-row (fn [{:keys [id library-file-ids]}] - (let [file (get-file-with-summary cfg id)] - (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{})))) + ;; One shared file that fails to load (for example, a migration + ;; error on old data) must not break the whole list; skip it + ;; and report it with its id. + (try + (let [file (get-file-with-summary cfg id)] + (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{}))) + (catch Exception cause + (l/error :hint "unable to load shared file" + :file-id (str id) + :team-id (str team-id) + :cause cause) + nil))) xform - (map process-row)] + (keep process-row)] (->> (db/plan conn [sql:team-shared-files team-id] {:fetch-size 1}) (transduce xform conj #{})))) @@ -1020,7 +1030,11 @@ (let [file (-> (bfc/get-file cfg file-id :include-deleted? true :lock-for-update? true) - (update :data ctf/absorb-assets ldata))] + (update :data ctf/absorb-assets ldata) + (update :data (fn [data] + (cond-> data + (cfo/effective-tokens-source? data (:id ldata)) + (cfo/absorb-tokens-lib ldata)))))] (l/trc :hint "library absorbed" :library-id (str (:id ldata)) @@ -1035,16 +1049,21 @@ :modified-at (ct/now) :has-media-trimmed false))))) +(defn- get-referenced-files + [cfg library-id] + (->> (db/exec! cfg [sql:get-referenced-files library-id]) + (into [] bfc/xf-map-id))) + (defn- absorb-library* - "Find all files using a shared library, and absorb all library assets - into the file local libraries" - [cfg {:keys [id data] :as library}] + "Absorb all library assets, and its tokens lib if it is their tokens + source, into the local libraries of the files that use it. The files + are the given file-ids, or the ones linked to the library if nil." + [cfg {:keys [id data] :as library} file-ids] (assert (db/connection-map? cfg) "expected cfg with valid connection") - (let [ids (->> (db/exec! cfg [sql:get-referenced-files id]) - (sequence bfc/xf-map-id))] + (let [ids (or file-ids (get-referenced-files cfg id))] (l/trc :hint "absorbing library" :library-id (str id) @@ -1054,7 +1073,9 @@ library)) (defn absorb-library - [{:keys [::db/conn] :as cfg} id] + "Absorb the library into the files that use it. The files are the given + file-ids, or the ones linked to the library if omitted." + [{:keys [::db/conn] :as cfg} id & {:keys [file-ids]}] (let [file (-> (bfc/get-file cfg id :realize? true :lock-for-update? true @@ -1070,7 +1091,7 @@ (-> (cfeat/get-team-enabled-features cf/flags team) (cfeat/check-file-features! (:features file))) - (absorb-library* cfg file))) + (absorb-library* cfg file file-ids))) (defn- set-file-shared [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] @@ -1143,7 +1164,10 @@ {:deleted-at (ct/in-future delay)} {:id file-id} {::db/return-keys [:id :name :is-shared :deleted-at - :project-id :created-at :modified-at]})] + :project-id :created-at :modified-at]}) + ;; Files using the library, captured before their relations are removed + file-ids (when (:is-shared file) + (get-referenced-files conn file-id))] ;; Remove all possible relations for that file (db/delete! conn :file-library-rel @@ -1151,9 +1175,11 @@ (jobs/submit cfg {::jobs/name :delete-object - ::jobs/params {:object :file - :deleted-at (:deleted-at file) - :id file-id}}) + ::jobs/params (cond-> {:object :file + :deleted-at (:deleted-at file) + :id file-id} + (some? file-ids) + (assoc :file-ids file-ids))}) file)) (def ^:private @@ -1181,7 +1207,8 @@ :project-id (:project-id file) :name (:name file) :created-at (:created-at file) - :modified-at (:modified-at file)}}))) + :modified-at (:modified-at file) + :deleted-at (:deleted-at file)}}))) (sv/defmethod ::delete-file {::doc/added "1.17" @@ -1402,8 +1429,16 @@ WHERE file_id = ANY(?::uuid[])") (def ^:private sql:restore-file-changes + ;; Only durable history comes back: a snapshot (carries `label`) or a legacy + ;; payload row (carries `data`, the column the new model replaces with + ;; `file_data`). Revisions have neither and are deliberately left alone: they + ;; are born already scheduled for deletion one hour later, and the collector + ;; only looks at rows that carry a date, so unmarking one here would keep it + ;; in the table for ever. The `data` half is a guard for the rows still + ;; pending migration, not a dependency: drop it with the last one. "UPDATE file_change SET deleted_at = null - WHERE file_id = ANY(?::uuid[])") + WHERE file_id = ANY(?::uuid[]) + AND (label IS NOT NULL OR data IS NOT NULL)") (def ^:private sql:restore-file-data "UPDATE file_data SET deleted_at = null diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index d1b14283a5..23959989af 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -137,5 +137,11 @@ {:id team-id} {::db/return-keys false})))) - (-> (create-file cfg params) - (vary-meta assoc ::audit/props {:team-id team-id})))) + (let [file (create-file cfg params)] + (vary-meta file assoc ::audit/props {:id (:id file) + :project-id project-id + :team-id team-id + :name (:name file) + :created-at (:created-at file) + :modified-at (:modified-at file) + :revn (:revn file)})))) diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index 6643554a06..66bb1447ad 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -12,9 +12,11 @@ [app.common.schema :as sm] [app.common.uuid :as uuid] [app.db :as db] + [app.loggers.audit :as-alias audit] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] [app.util.services :as sv])) ;; --- MUTATION: Create Share Link @@ -39,7 +41,11 @@ ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] (files/check-edition-permissions! conn profile-id file-id) - (create-share-link conn (assoc params :profile-id profile-id))) + (let [slink (create-share-link conn (assoc params :profile-id profile-id))] + ;; The consumers key share scope by file, so the event carries + ;; both ids; `:file-id` is already in params, `:share-id` is new. + (rph/with-meta slink + {::audit/props {:share-id (:id slink)}}))) (defn create-share-link [conn {:keys [profile-id file-id pages who-comment who-inspect]}] @@ -80,4 +86,8 @@ :hint "You can only delete share-links you created"))) (db/delete! conn :share-link {:id id}) - nil)) + ;; The delete params carry only the link id, so resolve the file + ;; from the row fetched above before it is gone. + (rph/with-meta (rph/wrap) + {::audit/props {:file-id (:file-id slink) + :share-id id}}))) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 1ff4e46d40..f156cae1bd 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -298,7 +298,8 @@ ::rtry/enabled true ::rtry/when rtry/conflict-exception? ::audit/skip true - ::sm/params schema:create-file-object-thumbnail} + ::sm/params schema:create-file-object-thumbnail + ::rpc/multipart-only-params [:media]} [cfg {:keys [::rpc/profile-id file-id object-id media tag]}] (media.v/validate-media-type! media) @@ -399,7 +400,8 @@ [:file-thumbnail-ops/global]] ::rtry/enabled true ::rtry/when rtry/conflict-exception? - ::sm/params schema:create-file-thumbnail} + ::sm/params schema:create-file-thumbnail + ::rpc/multipart-only-params [:media]} [cfg {:keys [::rpc/profile-id file-id] :as params}] (media.v/validate-media-type! (:media params)) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 06c630dfb3..c6a37586f6 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -202,7 +202,8 @@ (assoc :file file) (assoc :changes changes)) - cfg (assoc cfg ::timestamp (ct/now))] + cfg (assoc cfg ::timestamp (ct/now) + ::validation-timings (atom {}))] (when (not= (:vern params) (:vern file)) @@ -241,9 +242,39 @@ (binding [l/*context* (some-> (meta params) (get :app.http/request) (errors/request->context))] - (-> (update-file* cfg params tpoint) - (rph/with-defer #(let [elapsed (tpoint)] - (l/trace :hint "update-file" :time (ct/format-duration elapsed)))))))) + (try + (-> (update-file* cfg params tpoint) + (rph/with-defer #(let [elapsed (tpoint) + timings @(::validation-timings cfg)] + (l/dbg :hint "update-file" + :file-id (str (:id file)) + :project-id (str (:project-id file)) + :team-id (str (:team-id file)) + :status "success" + :time (ct/format-duration elapsed) + :resolve-libs-ms (get timings :resolve-libs-ms) + :process-changes-ms (get timings :process-changes-ms) + :soft-validate-affected-ms (get timings :soft-validate-affected-ms) + :soft-validate-schema-ms (get timings :soft-validate-schema-ms) + :validate-affected-ms (get timings :validate-affected-ms) + :validate-schema-ms (get timings :validate-schema-ms))))) + (catch Throwable cause + ;; NOTE: `with-defer` hooks only run on the success path + ;; (`handle-response`), so timings collected before a hard + ;; validation failure would be lost without this inline log. + (let [timings @(::validation-timings cfg)] + (l/dbg :hint "update-file" + :file-id (str (:id file)) + :project-id (str (:project-id file)) + :team-id (str (:team-id file)) + :status "error" + :resolve-libs-ms (get timings :resolve-libs-ms) + :process-changes-ms (get timings :process-changes-ms) + :soft-validate-affected-ms (get timings :soft-validate-affected-ms) + :soft-validate-schema-ms (get timings :soft-validate-schema-ms) + :validate-affected-ms (get timings :validate-affected-ms) + :validate-schema-ms (get timings :validate-schema-ms))) + (throw cause)))))) (defn- update-file* "Internal function, part of the update-file process, that encapsulates @@ -345,11 +376,18 @@ (with-meta {:revn revn :lagged (get-lagged-changes conn params)} {::audit/replace-props - (cond-> {:id (:id file) - :name (:name file) - :features (:features file) - :project-id (:project-id file) - :team-id (:team-id file)} + (cond-> {:id (:id file) + :name (:name file) + :features (:features file) + :project-id (:project-id file) + :team-id (:team-id file) + ;; NOTE: `file` carries the post-update revn here (`revn` + ;; above is the pre-update one kept for the response), and + ;; `modified-at` mirrors `persist-file!`: same `::timestamp` + ;; fallback, so both agree to the millisecond when the + ;; request timestamp exists. + :revn (:revn file) + :modified-at (or timestamp (ct/now))} ;; The ordinary save path is untouched: the props above are ;; the ones it has always carried. @@ -533,6 +571,16 @@ (catch Throwable cause (l/error :hint "file schema validation error" :cause cause)))) +(defn- record-validation-timing + [cfg step-key step-label elapsed-ms] + (when-let [metrics (::mtx/metrics cfg)] + (when (mtx/metrics? metrics) + (mtx/run! metrics {:id :update-file-validation-timing + :labels [step-label] + :val (double elapsed-ms)}))) + (when-let [timings (::validation-timings cfg)] + (swap! timings assoc step-key elapsed-ms))) + (defn- soft-validate-file! [file libs changes] (try @@ -543,11 +591,21 @@ (defn- process-changes-and-validate [cfg file changes skip-validate] - (let [libs - (when (and (or (contains? cf/flags :file-validation) - (contains? cf/flags :soft-file-validation)) - (not skip-validate)) - (bfc/get-resolved-file-libraries cfg file)) + (let [should-resolve-libs? + (and (or (contains? cf/flags :file-validation) + (contains? cf/flags :soft-file-validation)) + (not skip-validate)) + + tlibs + (ct/tpoint) + + libs + (try + (when should-resolve-libs? + (bfc/get-resolved-file-libraries cfg file)) + (finally + (when should-resolve-libs? + (record-validation-timing cfg :resolve-libs-ms "resolve-libs" (inst-ms (tlibs)))))) ;; The main purpose of this atom is provide a contextual state ;; for the changes subsystem where optionally some hints can @@ -557,12 +615,8 @@ state (atom {}) - file - (binding [cpc/*state* state] - (-> (files/check-version! file) - (update :revn inc) - (update :data cpc/process-changes changes) - (update :data d/without-nils))) + tproc + (ct/tpoint) ;; The media fix-up copies the foreign media rows into the file ;; and rewrites the references on the file data. A branch @@ -573,25 +627,50 @@ ;; row (`persist-branch-file!`), so the comparison reads the ;; pairing from the branch's own record. [file op-changes media-index] - (if-let [media-refs (-> @state :media-refs not-empty)] - (let [[file media-index] (bfc/update-media-references! cfg file media-refs)] - [file (bm/remap-changes changes media-index) media-index]) - [file changes nil])] + (try + (let [file + (binding [cpc/*state* state] + (-> (files/check-version! file) + (update :revn inc) + (update :data cpc/process-changes changes) + (update :data d/without-nils)))] + (if-let [media-refs (-> @state :media-refs not-empty)] + (let [[file media-index] (bfc/update-media-references! cfg file media-refs)] + [file (bm/remap-changes changes media-index) media-index]) + [file changes nil])) + (finally + (record-validation-timing cfg :process-changes-ms "process-changes" (inst-ms (tproc)))))] (binding [pmap/*tracked* nil] (when (contains? cf/flags :soft-file-validation) - (soft-validate-file! file libs changes)) + (let [t (ct/tpoint)] + (try + (soft-validate-file! file libs changes) + (finally + (record-validation-timing cfg :soft-validate-affected-ms "soft-validate-affected" (inst-ms (t))))))) (when (contains? cf/flags :soft-file-schema-validation) - (soft-validate-file-schema! file)) + (let [t (ct/tpoint)] + (try + (soft-validate-file-schema! file) + (finally + (record-validation-timing cfg :soft-validate-schema-ms "soft-validate-schema" (inst-ms (t))))))) (when (and (contains? cf/flags :file-validation) (not skip-validate)) - (val/validate-file-affected! file libs changes)) + (let [t (ct/tpoint)] + (try + (val/validate-file-affected! file libs changes) + (finally + (record-validation-timing cfg :validate-affected-ms "validate-affected" (inst-ms (t))))))) (when (and (contains? cf/flags :file-schema-validation) (not skip-validate)) - (val/validate-file-schema! file))) + (let [t (ct/tpoint)] + (try + (val/validate-file-schema! file) + (finally + (record-validation-timing cfg :validate-schema-ms "validate-schema" (inst-ms (t)))))))) [file op-changes media-index])) 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/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 62d5a88330..b0ac390b57 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -142,12 +142,29 @@ (when (uuid? profile-id) (teams/create-project-role conn profile-id (:id project) :owner)) + ;; The new entities are created by internal calls, so they carry no + ;; event of their own; emit one per entity for a local projection. + (audit/submit-create-event cfg params "create-project" + {:id (:id project) + :team-id (:team-id project) + :name (:name project) + :created-at (:created-at project) + :modified-at (:modified-at project)}) + (doseq [file-id files] (let [params (-> params (dissoc :name) (assoc :file-id file-id) - (assoc :reset-shared-flag false))] - (duplicate-file cfg params))) + (assoc :reset-shared-flag false)) + file (duplicate-file cfg params)] + (audit/submit-create-event cfg params "create-file" + {:id (:id file) + :project-id (:project-id file) + :team-id (:team-id project) + :name (:name file) + :created-at (:created-at file) + :modified-at (:modified-at file) + :revn (:revn file)}))) project)))) @@ -211,7 +228,7 @@ (update :team-id bfc/lookup-index) (assoc :created-at timestamp) (assoc :modified-at timestamp))] - (teams/add-profile-to-team! cfg params {::db/return-keys false}))) + (teams/add-profile-to-team! cfg params))) ;; Duplicate team fonts (doseq [font fonts] @@ -444,14 +461,12 @@ {:id project-id} {::db/return-keys false}) - (let [props (audit/clean-props params)] - (doseq [file-id result] - (let [props (assoc props :id file-id) - event (-> (audit/event-from-rpc-params params) - (assoc :profile-id profile-id) - (assoc :name "create-file") - (assoc :props props))] - (audit/submit cfg event)))))) + (doseq [file-id (:file-ids result)] + (audit/submit-file-created-event + cfg + (assoc params :profile-id profile-id) + file-id + {:team-id (:id team)})))) result)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index e914ff3302..c5a78df076 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -40,6 +40,9 @@ (declare create-file-media-object) +;; TEMPORARILY DISABLED (2026-10-19): the media-storage-bytes-per-team +;; quote check that used this lookup is disabled below. Kept active +;; because the audit props of the upload still report the team id. (def ^:private sql:get-team-id-for-file "SELECT p.team_id FROM file AS f @@ -57,6 +60,7 @@ {::doc/added "1.17" ::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]] ::sm/params schema:upload-file-media-object + ::rpc/multipart-only-params [:content] ::climit/id [[:process-image/by-profile ::rpc/profile-id] [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] @@ -65,30 +69,35 @@ (media.v/validate-media-size! content) (let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))] - (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team - ::quotes/profile-id profile-id - ::quotes/team-id team-id - ::quotes/incr (:size content)})) + ;; TEMPORARILY DISABLED (2026-10-19): media-storage-bytes-per-team + ;; quote performs a full SUM over storage_object per upload (3-12s in + ;; prod on a 3.5TB DB, ~34 slow executions/hour). Disabled until we + ;; land a cheaper approximation (cached counter / rewritten query). + ;; TODO: re-enable with the new implementation. + ;; (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team + ;; ::quotes/profile-id profile-id + ;; ::quotes/team-id team-id + ;; ::quotes/incr (:size content)}) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + ;; We get the minimal file for proper checking if + ;; file is not already deleted + (let [_ (files/get-minimal-file conn file-id) + mobj (create-file-media-object cfg params)] - (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - ;; We get the minimal file for proper checking if - ;; file is not already deleted - (let [_ (files/get-minimal-file conn file-id) - mobj (create-file-media-object cfg params)] + (db/update! conn :file + {:modified-at (ct/now) + :has-media-trimmed false} + {:id file-id} + {::db/return-keys false}) - (db/update! conn :file - {:modified-at (ct/now) - :has-media-trimmed false} - {:id file-id} - {::db/return-keys false}) - - (with-meta mobj - {::audit/replace-props - {:name (:name params) - :file-id file-id - :is-local (:is-local params) - :size (:size content) - :mtype (:mtype content)}}))))) + (with-meta mobj + {::audit/replace-props + {:name (:name params) + :file-id file-id + :team-id team-id + :is-local (:is-local params) + :size (:size content) + :mtype (:mtype content)}})))))) (defn- big-enough-for-thumbnail? "Checks if the provided image info is big enough for @@ -370,6 +379,7 @@ (sv/defmethod ::upload-chunk {::doc/added "2.17" ::sm/params schema:upload-chunk + ::rpc/multipart-only-params [:content] ::sm/result schema:upload-chunk-result} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id session-id index content] :as _params}] diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index f388d0dfc0..cefd382b72 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -307,6 +307,7 @@ (sv/defmethod ::update-profile-photo {:doc/added "1.1" ::sm/params schema:update-profile-photo + ::rpc/multipart-only-params [:file] ::sm/result :nil} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type @@ -540,7 +541,8 @@ {::doc/added "1.0" ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}] - (let [teams (get-owned-teams conn profile-id) + (let [profile (db/get* conn :profile {:id profile-id} {::db/remove-deleted false}) + teams (get-owned-teams conn profile-id) deleted-at (ct/now)] ;; If we found owned teams with participants, we don't allow @@ -581,6 +583,12 @@ (session/invalidate-all cfg profile-id) (-> (rph/wrap nil) + (rph/with-meta {::audit/props {:id (:id profile) + :email (:email profile) + :fullname (:fullname profile) + :created-at (:created-at profile) + :modified-at (:modified-at profile) + :deleted-at deleted-at}}) (rph/with-transform (session/delete-fn cfg))))) (def sql:get-subscription-editors diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 59151c3a84..3b77b9ba29 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -224,8 +224,9 @@ ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (let [params (assoc params :profile-id profile-id)] - (db/tx-run! cfg create-project params))) + (let [params (assoc params :profile-id profile-id) + project (db/tx-run! cfg create-project params)] + (rph/with-meta project {::audit/props {:id (:id project)}}))) ;; --- MUTATION: Toggle Project Pin diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 1273b4bc5b..f6af4116ab 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -564,7 +564,9 @@ team (db/tx-run! cfg create-team params)] (with-meta team - {::audit/props {:id (:id team)}}))) + {::audit/props {:id (:id team) + :created-at (:created-at team) + :modified-at (:modified-at team)}}))) (defn create-default-organization-team @@ -619,10 +621,42 @@ :default-team-id default-team-id})) default-team-id)))))))) +(defn- params->role + "Derive the team role from membership flags, inverse of + `types.team/permissions-for-role`. `team_profile_rel` carries no role + column, so no caller can pass it in `params`: derive it here so the event + always carries one." + [{:keys [is-owner is-admin can-edit]}] + (cond is-owner :owner is-admin :admin can-edit :editor :else :viewer)) + +(defn- submit-membership-event + "Emits an audit event for a new team membership, so the event stream carries + every membership add, including the ones that only happen as a side effect of + another command (team creation, invitation accepted without email roundtrip, + duplicated team). `event-opts` carries what the row lacks: `:actor-id` (the + action owner, defaulting to the member) and `:rpc-params` (request context, + which sibling events get from `prepare-rpc-event`)." + [cfg {:keys [profile-id team-id] :as params} {:keys [actor-id rpc-params]}] + (when (contains? cf/flags :audit-log) + (audit/submit cfg + (-> (audit/event-from-rpc-params (or rpc-params {})) + (assoc :name "add-team-member" + :type "action" + :profile-id (or actor-id profile-id)) + (assoc :props (d/without-nils + {:team-id team-id + :member-id profile-id + :role (params->role params) + :is-owner (boolean (:is-owner params)) + :is-admin (boolean (:is-admin params)) + :can-edit (boolean (:can-edit params))})))))) + (defn add-profile-to-team! ([cfg params] - (add-profile-to-team! cfg params nil)) - ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options] + (add-profile-to-team! cfg params nil nil)) + ([cfg params options] + (add-profile-to-team! cfg params options nil)) + ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options event-opts] (assert (db/connection-map? cfg) "expected cfg with valid connection") (when (contains? cf/flags :admin-console) @@ -632,7 +666,13 @@ (some? (:organization-id membership)) ;; the team do belong to an organization (not (:is-member membership))) ;; the user is not a member of the organization yet (initialize-user-in-organization cfg profile-id (:organization-id membership))))) - (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options))) + (let [options (merge options {::db/return-keys [:id]}) + result (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)] + ;; Only emit when a row was actually inserted: with `on-conflict-do-nothing` + ;; an existing membership returns nil and must not be reported twice. + (when (some? result) + (submit-membership-event cfg params event-opts)) + result))) (defn create-team "This is a complete team creation process, it creates the team @@ -885,7 +925,7 @@ ::jobs/params {:object :team :deleted-at (:deleted-at team) :id team-id}}) - (rph/with-meta (rph/wrap) + (rph/with-meta (rph/wrap team) {::audit/props {:organization-id organization-id :team-id team-id :team-name team-name}})))) @@ -900,7 +940,19 @@ ::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})) + (let [result (delete-team cfg {:team-id id :profile-id profile-id}) + team (rph/unwrap result)] + ;; The client keeps receiving nil; the audit event carries the deleted row. + ;; Both prop sets are kept: the organization ones (develop) and the entity + ;; identity and timestamps (staging); `with-meta` merges shallowly, so the + ;; maps are merged explicitly. + (rph/with-meta (rph/wrap) + {::audit/props (merge (::audit/props (meta result)) + {:id (:id team) + :name (:name team) + :created-at (:created-at team) + :modified-at (:modified-at team) + :deleted-at (:deleted-at team)})}))) ;; --- Mutation: Team Update Role @@ -967,7 +1019,13 @@ {::doc/added "1.17" ::sm/params schema:update-team-member-role} [cfg {:keys [::rpc/profile-id] :as params}] - (db/tx-run! cfg update-team-member-role (assoc params :profile-id profile-id))) + (db/tx-run! cfg update-team-member-role (assoc params :profile-id profile-id)) + ;; The client keeps receiving nil; when ownership moves, the audit event + ;; names the demoted member so a projection can keep a single owner. + (rph/with-meta (rph/wrap) + (if (= :owner (:role params)) + {::audit/props {:prev-owner-id profile-id}} + {}))) ;; --- Mutation: Delete Team Member @@ -1036,7 +1094,8 @@ (sv/defmethod ::update-team-photo {::doc/added "1.17" - ::sm/params schema:update-team-photo} + ::sm/params schema:update-team-photo + ::rpc/multipart-only-params [:file]} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type @@ -1045,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)] @@ -1061,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)}}))) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 6277506bda..6cabb06ec5 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -202,7 +202,10 @@ (when (contains? cf/flags :admin-console) (teams/initialize-user-in-organization cfg (:id member) (:id organization) email)) ;; Insert the invited member to the team - (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})) + (teams/add-profile-to-team! cfg params + {::db/on-conflict-do-nothing? true} + {:actor-id (:id profile) + :rpc-params params})) ;; If profile is not yet verified, mark it as verified because ;; accepting an invitation link serves as verification. @@ -318,7 +321,7 @@ :role :editor)))) (defn- add-member-to-team - [{:keys [::db/conn] :as cfg} profile team role member] + [{:keys [::db/conn] :as cfg} profile team role member event-opts] (assert (db/connection-map? cfg) "expected cfg with valid connection") @@ -340,7 +343,9 @@ ::quotes/team-id team-id}) ;; Insert the member to the team - (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) + (teams/add-profile-to-team! cfg params + {::db/on-conflict-do-nothing? true} + event-opts) ;; Delete any request (db/delete! conn :team-access-request @@ -438,7 +443,9 @@ (filter #(contains? invitation-emails (key %))) (map (fn [[email member]] (let [role (:role (first (filter #(= (:email %) email) invitation-data)))] - (add-member-to-team cfg profile team role member)))) + (add-member-to-team cfg profile team role member + {:actor-id (:id profile) + :rpc-params params})))) (doall)) invitations)) @@ -593,7 +600,7 @@ :hint "the maximum of invitation on single request is reached" :threshold max-invitations-by-request-threshold)) - (let [props {:name name :features features} + (let [props {:id (:id team) :name name :features features} event (-> (audit/event-from-rpc-params params) (assoc :name "create-team") (assoc :props props))] @@ -609,7 +616,8 @@ (map (fn [email] (assoc params :email email))) (map (partial create-invitation cfg)))] - (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) + (vary-meta team assoc ::audit/props {:id (:id team) + :invitations (count invitations)})))) ;; --- Query: get-team-invitation-token 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/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 2476128e7c..f22a693fd2 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -14,10 +14,12 @@ [app.common.uuid :as uuid] [app.db :as db] [app.http.client :as http] + [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as webhooks] [app.rpc :as-alias rpc] [app.rpc.commands.teams :refer [check-read-permissions!] :as t] [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.util.services :as sv] [cuerdas.core :as str])) @@ -137,7 +139,8 @@ (let [whook (-> (db/get pool :webhook {:id id}) (decode-row))] (check-webhook-edition-permissions! pool profile-id (:team-id whook)) (validate-webhook! cfg whook params) - (update-webhook! cfg whook params))) + (rph/with-meta (update-webhook! cfg whook params) + {::audit/props {:team-id (:team-id whook)}}))) (def ^:private schema:delete-webhook [:map {:title "delete-webhook"} @@ -151,7 +154,8 @@ (let [whook (-> (db/get conn :webhook {:id id}) decode-row)] (check-webhook-edition-permissions! conn profile-id (:team-id whook)) (db/delete! conn :webhook {:id id}) - nil)) + (rph/with-meta (rph/wrap) + {::audit/props {:team-id (:team-id whook)}}))) ;; --- Query: Webhooks diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index dabca00835..332fe23984 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -31,6 +31,7 @@ (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} [cfg {:keys [::rpc/profile-id content]}] (media.v/validate-media-type! content cm/tempfile-types) diff --git a/backend/src/app/rpc/management/files.clj b/backend/src/app/rpc/management/files.clj new file mode 100644 index 0000000000..d947ec7549 --- /dev/null +++ b/backend/src/app/rpc/management/files.clj @@ -0,0 +1,238 @@ +;; 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.management.files + "Read-only file content for the nexus file-security pipeline. + + The single method `get-file-for-analysis` returns the semantic + projection of one file (pages, frames, text, links, interactions): + small, neutral data the decision model consumes. Full file binaries + never cross this boundary. Every call leaves an audit event like any + other management call, so content reads stay traceable; the volume + scales with analyzed files, and the nexus consumer ignores the event." + (:require + [app.binfile.common :as bfc] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.text :as txt] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.doc :as doc] + [app.util.services :as sv] + [cuerdas.core :as str])) + +(def projection-version 1) + +;; 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. +(def ^:const max-text-chars 30000) +(def ^:const max-links 1000) +(def ^:const max-interactions 2000) + +(defn- page-shapes + "All shapes of one page with their page id attached." + [{:keys [id objects]}] + (let [page-id (str id)] + (->> (vals objects) + (filter map?) + (map #(assoc % :page-id page-id))))) + +(defn- shape-text + "Plain text of a text shape, or nil when it has none. Never throws: + corrupt content skips the shape instead of failing the file." + [shape] + (when (and (= :text (:type shape)) + (some? (:content shape))) + (let [text (ex/ignoring (txt/content->text (:content shape)))] + (when-not (str/blank? text) + text)))) + +(defn- open-url? + [{:keys [action-type url]}] + (and (= :open-url action-type) + (not (str/blank? url)))) + +(defn file->projection + "Turn decoded file data into the neutral projection the decision + model consumes. Pure: takes the `:data` map, returns plain data + with all ids as strings." + [{:keys [pages pages-index]}] + (let [shapes (mapcat page-shapes + (keep #(get pages-index %) pages)) + frames (into [] + (comp (filter #(= :frame (:type %))) + (map (fn [{:keys [id name page-id]}] + (d/without-nils + {:id (str id) + :page-id page-id + :name name})))) + shapes) + texts (into [] + (comp (map (juxt :id :page-id shape-text)) + (remove #(nil? (nth % 2))) + (map (fn [[id page-id text]] + {:shape-id (str id) + :page-id page-id + :text text}))) + shapes) + ;; Texts are capped by total characters, the unit the model + ;; bills; links and interactions by count. + texts (loop [acc [] + chars 0 + [item & rest] texts] + (cond + (nil? item) + {:items acc :truncated? false} + + (> (+ chars (count (:text item))) max-text-chars) + {:items acc :truncated? true} + + :else + (recur (conj acc item) + (+ chars (count (:text item))) + rest))) + inter (into [] + (comp (mapcat (fn [shape] + (->> (:interactions shape) + (filter map?) + (filter :action-type) + (filter :event-type) + (map #(assoc % :shape-id (:id shape) + :page-id (:page-id shape)))))) + (map (fn [{:keys [shape-id page-id event-type action-type destination url]}] + (d/without-nils + {:shape-id (str shape-id) + :page-id page-id + :event (d/name event-type) + :action (d/name action-type) + :target (or url (some-> destination str))})))) + shapes) + links (into [] + (comp (mapcat (fn [shape] + (->> (:interactions shape) + (filter map?) + (filter open-url?) + (map #(assoc % :shape-id (:id shape) + :page-id (:page-id shape)))))) + (map (fn [{:keys [shape-id page-id url]}] + {:shape-id (str shape-id) + :page-id page-id + :url url}))) + shapes)] + {:version projection-version + :pages (mapv (fn [page-id] + (let [page (get pages-index page-id)] + (d/without-nils + {:id (str page-id) + :name (:name page)}))) + pages) + :frames frames + :texts texts + :links (if (> (count links) max-links) + {:items (subvec links 0 max-links) :truncated? true} + {:items links :truncated? false}) + :interactions (if (> (count inter) max-interactions) + {:items (subvec inter 0 max-interactions) :truncated? true} + {:items inter :truncated? false})})) + +(def ^:private sql:has-share-link + "SELECT EXISTS (SELECT 1 FROM share_link WHERE file_id = ?) AS shared;") + +(def ^:private sql:get-team-id + "SELECT team_id FROM project WHERE id = ?;") + +(def ^:private + schema:get-file-for-analysis-params + [:map {:title "get-file-for-analysis-params"} + [:file-id ::sm/uuid]]) + +(def ^:private schema:projection-page + [:map + [:id :string] + [:name {:optional true} :string]]) + +(def ^:private schema:projection-frame + [:map + [:id :string] + [:page-id :string] + [:name {:optional true} :string]]) + +(def ^:private schema:projection-text + [:map + [:shape-id :string] + [:page-id :string] + [:text :string]]) + +(def ^:private schema:projection-link + [:map + [:shape-id :string] + [:page-id :string] + [:url :string]]) + +(def ^:private schema:projection-interaction + [:map + [:shape-id :string] + [:page-id :string] + [:event :string] + [:action :string] + [:target {:optional true} :string]]) + +(def ^:private schema:projection + [:map {:title "file-projection"} + [:version ::sm/int] + [:pages [:vector schema:projection-page]] + [:frames [:vector schema:projection-frame]] + [:texts [:map + [:items [:vector schema:projection-text]] + [:truncated? :boolean]]] + [:links [:map + [:items [:vector schema:projection-link]] + [:truncated? :boolean]]] + [:interactions [:map + [:items [:vector schema:projection-interaction]] + [:truncated? :boolean]]]]) + +(def ^:private + schema:get-file-for-analysis-result + [:map {:title "get-file-for-analysis-result"} + [:id ::sm/uuid] + [:revn ::sm/int] + [:modified-at ::ct/inst] + [:project-id ::sm/uuid] + [:team-id ::sm/uuid] + [:has-share-link :boolean] + [:projection-version ::sm/int] + [:projection schema:projection]]) + +(sv/defmethod ::get-file-for-analysis + "Get the semantic projection of one file for security analysis. + + Read-only: returns the file identity, revision, share state and the + neutral projection (pages, frames, text, links, interactions). + Called by nexus with the shared-key identity; takes no profile. + Every call leaves an audit event like any other management call." + {::doc/added "2.20" + ::sm/params schema:get-file-for-analysis-params + ::sm/result schema:get-file-for-analysis-result + ::rpc/auth false} + [cfg {:keys [file-id]}] + ;; `realize?` loads the whole object tree eagerly: the projection + ;; walks every shape, so lazy pointers (no `load-fn` bound here) + ;; would fail on first touch. Same choice as the file exporter. + (let [file (bfc/get-file cfg file-id :realize? true) + shared (:shared (db/exec-one! cfg [sql:has-share-link file-id])) + team-id (:team-id (db/exec-one! cfg [sql:get-team-id (:project-id file)]))] + {:id (:id file) + :revn (:revn file) + :modified-at (:modified-at file) + :project-id (:project-id file) + :team-id team-id + :has-share-link (boolean shared) + :projection-version projection-version + :projection (file->projection (:data file))})) 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/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 751388c27f..c8c4f83e8c 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -194,6 +194,7 @@ collection when replacing an existing one." {::doc/added "2.18" ::sm/params schema:upload-organization-logo + ::rpc/multipart-only-params [:content] ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] @@ -1059,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/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 3961da68f4..4372007f53 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -428,7 +428,7 @@ WHERE fc.file_id = ? AND fc.created_by = 'user' AND fc.deleted_at IS NULL - AND fc.data IS NOT NULL") + AND fc.label IS NOT NULL") (defmethod check-quote ::snapshots-per-file [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as cfg}] @@ -461,7 +461,7 @@ WHERE p.team_id = ? AND fc.created_by = 'user' AND fc.deleted_at IS NULL - AND fc.data IS NOT NULL") + AND fc.label IS NOT NULL") (defmethod check-quote ::snapshots-per-team [{:keys [::profile-id ::team-id ::target] :as cfg}] diff --git a/backend/src/app/scripts/decode_file_data.clj b/backend/src/app/scripts/decode_file_data.clj new file mode 100644 index 0000000000..b7d775eafc --- /dev/null +++ b/backend/src/app/scripts/decode_file_data.clj @@ -0,0 +1,300 @@ +;; 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.scripts.decode-file-data + "Decode a file's `file_data` binary blob to `jq`-readable JSON. + + The content of `file_data.data` (and `file.data` on the `legacy-db` + backend, or the `file-data` object on storage) is not text: it is a blob + encoded with `app.util.blob` (versions 1, 3, 4 and 5; transit+json or + fressian plus lz4/zstd). That is why it cannot be inspected with SQL + directly. + + Two uses: + + 1. Offline (no DB, just the blob): dump the `bytea` to a file with + `psql` (`\\copy ...` or `encode(data, 'base64')`) and pass it with `--input`. + Needs no system and no `file_data` read permissions. + + backend/scripts/decode-file-data --input /tmp/blob.bin --format summary | jq . + backend/scripts/decode-file-data --input /tmp/blob.b64 --from-base64 --format pages | jq . + + 2. Inside the penpot context (nREPL with the backend running, where + `sys/system` already exists): call `get-file-json` or `get-snapshot-json`. + They resolve the backend (`legacy-db`, `db`, `storage`), realize the + pointer-maps and objects-maps and return JSON. + + (require '[app.scripts.decode-file-data :as dfd] :reload) + (spit \"/tmp/maquette.json\" + (dfd/get-file-json sys/system #uuid \"9e056cfe-f83f-81f8-8008-cc885732f00d\" + :format :full)) + (spit \"/tmp/snap-518.json\" + (dfd/get-snapshot-json sys/system #uuid \"9e056cfe-f83f-81f8-8008-cc885732f00d\" + #uuid \"\" :format :summary)) + + Then filter with `jq`: `.pages`, `.data[\"pages-index\"] | keys`, + `[.data[\"pages-index\"][] | {id, name}]`, etc." + (:require + [app.binfile.common :as bfc] + [app.common.json :as json] + [app.db :as db] + [app.features.file-snapshots :as fsnap] + [app.util.blob :as blob] + [app.util.objects-map :as omap-legacy] + [app.util.pointer-map :as pmap] + [clojure.java.io :as io] + [clojure.string :as str]) + (:import + java.util.Base64)) + +(set! *warn-on-reflection* true) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; JSON sanitization +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- sanitize-key + [k] + (cond + (keyword? k) (subs (str k) 1) + (symbol? k) (str k) + (uuid? k) (str k) + :else (str k))) + +(declare sanitize) + +(defn- sanitize-map + [m] + (persistent! + (reduce-kv (fn [acc k v] + (assoc! acc (sanitize-key k) (sanitize v))) + (transient {}) + m))) + +(defn sanitize + "Convert a blob-decoded value to plain JSON-ready data. + + - Unrealized pointers become `{\"$pointer\": \"\"}`. + - `objects-map` instances are materialized to a plain map. + - Bytes become `{\"$bytes\": \"\"}`. + - UUIDs, instants, keywords and symbols become strings." + [x] + (cond + (nil? x) nil + (string? x) x + (boolean? x) x + + (number? x) + (if (ratio? x) (double x) x) + + (keyword? x) (subs (str x) 1) + (symbol? x) (str x) + (uuid? x) (str x) + (inst? x) (str x) + + (bytes? x) + {"$bytes" (.encodeToString (Base64/getEncoder) ^bytes x)} + + (pmap/pointer-map? x) + {"$pointer" (str (pmap/get-id x))} + + (omap-legacy/objects-map? x) + (sanitize (into {} x)) + + (map? x) + (sanitize-map x) + + (set? x) + (mapv sanitize x) + + (coll? x) + (mapv sanitize x) + + :else + (str x))) + +(defn data->json + "Encode sanitizable data to a JSON string." + [data] + (json/encode (sanitize data))) + +(defn blob->json + "Decode a `file_data` blob (`app.util.blob`) and return it as JSON." + [^bytes blob-bytes] + (data->json (blob/decode blob-bytes))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; jq-oriented investigation summary +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn summarize-data + "Small, stable summary for `jq`: pages (in `:pages` order) with object + counts, plus component, color and typography counters. Takes the already + decoded `:data` (not the JSON)." + [data] + (let [pages (:pages data) + pages-index (:pages-index data) + page-entry (fn [pid] + {:id (str pid) + :name (get-in pages-index [pid :name]) + :objects (count (get-in pages-index [pid :objects]))})] + {:pages (mapv page-entry (or pages [])) + :stats {:pages (count (or pages [])) + :components (count (:components data)) + :colors (count (:colors data)) + :typographies (count (:typographies data))}})) + +(defn file-data-summary-json + [data] + (data->json (summarize-data data))) + +(defn file-data-pages-json + [data] + (data->json (:pages (summarize-data data)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; DB access (for use from nREPL with the backend running) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- write-output + [^String payload output] + (if output + (do (spit output payload) output) + (print payload)) + payload) + +(defn get-file-json + "Return a file as JSON (realized). For nREPL. + + `format` can be `:full` (whole file), `:data` (`:data` only), + `:summary` or `:pages`. With `:output` it also writes it to a file." + [system file-id & {:keys [format output] :or {format :full}}] + (let [file (db/run! system bfc/get-file file-id :realize? true) + data (case format + :full file + :data (:data file) + :summary (summarize-data (:data file)) + :pages (:pages (summarize-data (:data file))))] + (write-output (data->json data) output))) + +(defn get-snapshot-json + "Return a snapshot as JSON (realized). For nREPL. + + Takes the same `format` and `output` as `get-file-json`." + [system file-id snapshot-id & {:keys [format output] :or {format :full}}] + (let [snapshot (db/run! system + (fn [cfg] + (fsnap/get-snapshot cfg file-id snapshot-id))) + data (case format + :full snapshot + :data (:data snapshot) + :summary (summarize-data (:data snapshot)) + :pages (:pages (summarize-data (:data snapshot))))] + (write-output (data->json data) output))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Offline CLI: decode a file holding the blob +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private cli-usage + (str "Usage: decode-file-data --input PATH [--output PATH] [--format full|data|summary|pages]\n" + " [--from-base64|--from-hex]\n" + "\n" + "Decode a file_data blob (app.util.blob) to JSON for inspection with jq.\n" + "\n" + "Examples:\n" + " psql -c \"\\\\copy (select data from file_data where file_id='...' and id='...') to '/tmp/blob.bin' with binary\"\n" + " decode-file-data --input /tmp/blob.bin --format summary | jq .\n" + " decode-file-data --input /tmp/blob.b64 --from-base64 --format pages | jq .\n")) + +(defn- parse-args + [args] + (loop [args args + opts {:format "full"}] + (if (empty? args) + opts + (let [[a b & rest-args] args] + (cond + (contains? #{"-h" "--help"} a) + (assoc opts :help true) + + (= a "--input") + (recur rest-args (assoc opts :input b)) + + (= a "--output") + (recur rest-args (assoc opts :output b)) + + (= a "--format") + (recur rest-args (assoc opts :format b)) + + (= a "--from-base64") + (recur (cons b rest-args) (assoc opts :from :base64)) + + (= a "--from-hex") + (recur (cons b rest-args) (assoc opts :from :hex)) + + :else + (assoc opts :error (str "unknown argument: " a))))))) + +(defn- hex->bytes + [^String s] + (let [s (str/trim s) + s (cond + (str/starts-with? s "\\x") (subs s 2) + (str/starts-with? s "0x") (subs s 2) + :else s) + s (str/replace s #"\s+" "") + n (/ (count s) 2) + out (byte-array n)] + (dotimes [i n] + (aset-byte out i (unchecked-byte (Integer/parseInt (subs s (* i 2) (+ (* i 2) 2)) 16)))) + out)) + +(defn- read-input-bytes + [{:keys [input from]}] + (cond + (nil? input) + (throw (ex-info "missing --input" {})) + + (= from :base64) + (.decode (Base64/getDecoder) ^String (str/trim (slurp (io/file input)))) + + (= from :hex) + (hex->bytes (slurp (io/file input))) + + :else + (java.nio.file.Files/readAllBytes (.toPath (io/file input))))) + +(defn -main + [& args] + (let [{:keys [help error input format output] :as opts} (parse-args (vec args))] + (cond + help + (do (print cli-usage) nil) + + error + (do (binding [*out* *err*] (println error) (println cli-usage)) (System/exit 2)) + + (nil? input) + (do (binding [*out* *err*] (println "missing --input") (println cli-usage)) (System/exit 2)) + + (not (contains? #{"full" "data" "summary" "pages"} (or format "full"))) + (do (binding [*out* *err*] (println (str "unknown format: " format))) (System/exit 2)) + + :else + (let [blob-bytes (read-input-bytes opts) + decoded (blob/decode blob-bytes) + payload (case format + "full" decoded + "data" (:data decoded (:data decoded decoded)) + "summary" (summarize-data (:data decoded decoded)) + "pages" (:pages (summarize-data (:data decoded decoded)))) + ;; NOTE: the file_data blob already IS the file's :data; but if + ;; someone dumps the whole serialized row, tolerate both. + out (data->json payload)] + (if output + (spit output out) + (println out)))))) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index a205320242..51075ecda0 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -783,7 +783,7 @@ :type "action" :props {:id team-id} :context {:triggered-by "srepl" - :cause "explicit call to delete-profile!"} + :cause "explicit call to delete-team!"} :tracked-at tnow}) (jobs/invoke (-> sys/system @@ -852,6 +852,7 @@ (audit/insert sys/system {:name "delete-profile" :type "action" + :props {:id profile-id} :context {:triggered-by "srepl" :cause "explicit call to delete-profile!"} :tracked-at tnow}) @@ -1040,4 +1041,4 @@ (let [params (-> rel (assoc :id (uuid/next)) (assoc :team-id (:id team)))] - (teams/add-profile-to-team! cfg params {::db/return-keys false})))))))) + (teams/add-profile-to-team! cfg params)))))))) diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index bdc3cc3bef..aacc2d7ac3 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -19,201 +19,175 @@ [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] - [clojure.set :as set] [integrant.core :as ig])) -(def ^:private max-attempts - "Maximum number of deletion attempts before giving up and accepting - the orphan blob." - 7) - -(def ^:private chunk-size - "Pending objects deleted in each transaction." - 25) - -(def ^:private sql:lock-sobjects - "SELECT id FROM storage_object - WHERE id = ANY(?::uuid[]) - FOR UPDATE - SKIP LOCKED") - -(defn- lock-ids - "Perform a select before delete for proper object locking and - prevent concurrent operations and we proceed only with successfully - locked objects." - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-sobjects ids]) - (into #{} (map :id)) - (not-empty)))) - (def ^:private sql:delete-sobjects "DELETE FROM storage_object WHERE id = ANY(?::uuid[])") -(defn- delete-sobjects - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (-> (db/exec-one! conn [sql:delete-sobjects ids]) - (db/get-update-count)))) +(defn- delete-by-backend + "Deletes the locked objects of one backend group: the database rows and + their backend content. Runs inside the caller's transaction. Returns the + number of deleted rows; reporting it (metrics) is the caller's job, once + the transaction has committed." + [{:keys [::db/conn ::sto/storage]} backend-id ids] + (let [ids-array (db/create-array conn "uuid" ids) + total (-> (db/exec-one! conn [sql:delete-sobjects ids-array]) + (db/get-update-count))] + (-> (impl/resolve-backend storage backend-id) + (impl/del-objects-in-bulk ids)) -(def ^:private sql:delete-upload-session-chunks - "DELETE FROM upload_session_chunk - WHERE object_id = ANY(?::uuid[])") - -(defn- delete-upload-session-chunks - "Remove the chunk mappings for the given storage object ids. This must run - before the storage_object rows are deleted: the upload_session_chunk - foreign keys are ON DELETE NO ACTION." - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (db/exec-one! conn [sql:delete-upload-session-chunks ids]))) - -(def ^:private sql:increment-attempts-and-defer - "UPDATE storage_object - SET deletion_attempts = deletion_attempts + 1, - deleted_at = NOW() + INTERVAL '1 day' - WHERE id = ANY(?::uuid[])") - -(defn- increment-attempts-and-defer - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (db/exec-one! conn [sql:increment-attempts-and-defer ids]))) - -(def ^:private sql:delete-give-up - "DELETE FROM storage_object - WHERE id = ANY(?::uuid[]) - AND deletion_attempts >= ?") - -(defn- delete-give-up - [conn ids] - (let [ids (db/create-array conn "uuid" ids)] - (db/exec-one! conn [sql:delete-give-up ids max-attempts]))) - -(defn- process-chunk - "Attempt to delete a chunk of storage objects from a specific backend. - - This function runs inside the chunk's own transaction (see - `delete-in-bulk`) — it does NOT open its own transaction. The rows are - locked via FOR UPDATE SKIP LOCKED before deleting. - - Returns the number of successfully deleted objects, or 0 if no rows - could be locked." - [conn storage backend-id ids] - (if-let [locked-ids (lock-ids conn ids)] - (let [fail-ids (try - (-> (impl/resolve-backend storage backend-id) - (impl/del-objects-in-bulk locked-ids)) - (catch Throwable cause - (l/err :hint "error on physical deletion, will retry" - :ids locked-ids - :cause cause) - locked-ids)) - ok-ids (set/difference locked-ids fail-ids)] - - (doseq [id ok-ids] - (l/dbg :hint "permanently delete storage object" - :id (str id) - :backend (name backend-id))) - - (when (seq ok-ids) - ;; NOTE: the chunk mappings must be removed before the - ;; storage_object rows (NO ACTION foreign keys). It only affects - ;; objects of the upload-session bucket; for any other bucket the - ;; delete matches no rows. - (delete-upload-session-chunks conn ok-ids) - (delete-sobjects conn ok-ids)) - - (when (seq fail-ids) - (increment-attempts-and-defer conn fail-ids) - ;; NOTE: same NO ACTION ordering as above: the give-up DELETE below - ;; removes storage_object rows, so chunk mappings must go first. - ;; Deferred objects keep their rows; only the mapping of a - ;; permanently given-up object disappears early, and that object is - ;; already deleted-marked. - (delete-upload-session-chunks conn fail-ids) - (let [given-up (delete-give-up conn fail-ids)] - (when (pos? (db/get-update-count given-up)) - (l/wrn :hint "giving up on orphan blob after max attempts" - :ids fail-ids - :max-attempts max-attempts)))) - - (count ok-ids)) - 0)) - -(defn- delete-in-bulk - [cfg backend-id ids] - ;; Each chunk runs in its own transaction, on its own connection: a chunk - ;; that fails (for example, an object that a foreign key still references) is - ;; logged and skipped, and it cannot poison the cursor that feeds the rest. - (try - (db/tx-run! cfg - (fn [{:keys [::db/conn ::sto/storage]}] - (process-chunk conn storage backend-id ids))) - (catch Throwable cause - (l/err :hint "unexpected error on bulk deletion, skipping chunk" - :ids ids - :cause cause) - 0))) + (doseq [id ids] + (l/dbg :hint "permanently delete storage object" + :id (str id) + :backend (name backend-id))) + total)) (defn- group-by-backend - [items] - (d/group-by (comp keyword :backend) :id #{} items)) + [rows] + (d/group-by (comp keyword :backend) :id #{} rows)) + +(defn- process-chunk-deletion + "Deletes the locked rows of one chunk, grouped by backend. Runs inside the + caller's transaction: any failure here aborts it, so the whole chunk is + rolled back (nothing is deleted and the locks are released) and skipped by + the caller. Returns a map of backend id to deleted row count." + [tx rows] + (->> (group-by-backend rows) + (map (fn [[backend-id ids]] + [backend-id (delete-by-backend tx backend-id ids)])) + (into {}))) (def ^:private sql:get-deleted-sobjects - "SELECT id, backend - FROM storage_object - WHERE deleted_at IS NOT NULL - AND deleted_at <= ? - AND status = 'valid' - ORDER BY deleted_at ASC") + "SELECT s.id, s.backend, s.deleted_at + FROM storage_object AS s + WHERE s.deleted_at IS NOT NULL + AND s.deleted_at <= ? + AND s.status = 'valid' + AND (s.deleted_at, s.id) > (?, ?) + ORDER BY s.deleted_at ASC, s.id ASC + LIMIT ? + FOR UPDATE SKIP LOCKED") -(defn- pending-row - "Takes the columns the GC needs from a `db/plan` row. +(def ^:private sql:get-first-deleted-sobjects + "SELECT s.id, s.backend, s.deleted_at + FROM storage_object AS s + WHERE s.deleted_at IS NOT NULL + AND s.deleted_at <= ? + AND s.status = 'valid' + ORDER BY s.deleted_at ASC, s.id ASC + LIMIT ? + FOR UPDATE SKIP LOCKED") - A plan row is a view over the open result set, valid only while the reduction - is running, so it cannot be stored for later: this has to happen inside the - reducing function." - [row] - {:id (:id row) - :backend (:backend row)}) +(def ^:private chunk-size + "Pending objects read, locked and deleted in each transaction." + 25) + +(def ^:private throttle-every + "Deleted objects between throttle pauses." + 5000) + +(def ^:private throttle-ms + "Pause applied every `throttle-every` deleted objects, letting the + database (and its replicas) breathe on huge backlogs." + 5000) + +(defn- throttle + "Sleeps `throttle-ms` when the deleted count crosses a `throttle-every` + boundary. Runs between chunk transactions, never holding one open." + [prev-total new-total] + (when (> (quot new-total throttle-every) + (quot prev-total throttle-every)) + (l/inf :hint "throttling storage gc" + :deleted new-total + :pause-ms throttle-ms) + (Thread/sleep (long throttle-ms)))) + +(defn- fetch-and-lock-chunk + "Reads and locks one chunk of pending objects in `(deleted_at, id)` order. + Objects already locked by another process are skipped, so two concurrent + runs never take the same chunk. Runs inside the caller's transaction." + [conn last-deleted-at last-id] + (let [[sql params] (if (some? last-deleted-at) + [sql:get-deleted-sobjects + [(ct/now) last-deleted-at last-id chunk-size]] + [sql:get-first-deleted-sobjects + [(ct/now) chunk-size]])] + (db/exec! conn (into [sql] params)))) (defn- clean-deleted - "Walks the pending objects with `db/plan`, a server-side cursor that streams - the rows instead of materialising them, and deletes them chunk by chunk. + "Processes the pending objects chunk by chunk with keyset pagination: each + chunk is one short transaction that reads and locks the next `chunk-size` + objects after the `(deleted_at, id)` cursor and deletes them (database rows + and backend content). Only `valid` objects are read: pending ones (still + being written, see the status lifecycle) stay untouched even when already + marked as deleted. - The rows are read in their own read-only transaction and every chunk of - `chunk-size` objects is deleted in its own one (see `delete-in-bulk`), so a - chunk that fails is logged and skipped instead of aborting the whole run, and - the work of the previous chunks is already committed. A physical-deletion - failure inside a chunk defers its rows one day and gives up after - `max-attempts` attempts (see `process-chunk`)." - [cfg] - (let [xform (comp (map pending-row) - (partition-all chunk-size) - (mapcat group-by-backend)) - rf (fn - ([total] total) - ([total [backend-id ids]] - (let [deleted (or (delete-in-bulk cfg backend-id ids) 0)] - ;; Beat the job after every chunk: each chunk commits on its - ;; own, so a long sweep neither loses work on a late failure - ;; nor outruns the orphan lease. - (jobs/heartbeat cfg) - (+ total deleted))))] - (db/tx-run! (assoc cfg ::db/read-only true) - (fn [{:keys [::db/conn]}] - (transduce xform rf 0 - (db/plan conn [sql:get-deleted-sobjects (ct/now)])))))) + A chunk that fails aborts its transaction: nothing of the chunk is deleted, + its locks are released, and it is logged and skipped. The cursor advances + to the `(deleted_at, id)` of the last row of each read chunk regardless of + the deletion result, so a failed chunk is not visited again in the same + run. There is no long-lived read transaction: each transaction lives only + as long as one chunk, letting the vacuum proceed between chunks. Rows + locked by a concurrent run are skipped and deferred to the next run; a + chunk that fails after a partial backend delete self-heals on the next + run (physical deletes are idempotent). Every `throttle-every` deleted + objects the run pauses `throttle-ms` (see `throttle`)." + [{:keys [::mtx/metrics] :as cfg}] + (loop [total 0 + last-deleted-at nil + last-id nil] + + (let [[rows counts] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as tx}] + (let [rows (fetch-and-lock-chunk conn last-deleted-at last-id)] + (try + [rows (process-chunk-deletion tx rows)] + (catch Throwable cause + ;; Explicit rollback, then return normally (do + ;; not rethrow): the caller needs `rows` to + ;; advance the keyset cursor past the failed + ;; chunk. The commit that `tx-run!` performs + ;; afterwards is a JDBC no-op on the already + ;; rolled-back connection. + (db/rollback! conn) + (l/err :hint "unexpected error on chunk deletion" + :ids (map :id rows) + :cause cause) + [rows {}])))))] + + ;; Reported only after the chunk commits: a rolled-back chunk leaves + ;; an empty count map, so retried rows are never counted twice. + (doseq [[backend-id n] counts] + (mtx/run! metrics + :id :storage-gc-deleted-objects + :inc n + :labels [(name backend-id)])) + + ;; Beat the job after every chunk: each chunk commits on its own, so a + ;; long sweep neither loses work on a late failure nor outruns the + ;; orphan lease. + (jobs/heartbeat cfg) + + (if-let [cursor (peek rows)] + (let [new-total (+ total (reduce + 0 (vals counts)))] + (throttle total new-total) + (recur new-total + (:deleted-at cursor) + (:id cursor))) + total)))) (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")) + (assert (db/pool? (::db/pool params)) "expect valid db pool") + (assert (mtx/metrics? (::mtx/metrics params)) "expect valid metrics instance")) (def schema:storage-gc-deleted-params "Params map (no params needed; cfg-provided config only)." diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index 82394e8f7b..80e411d9da 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -96,6 +96,15 @@ (-> (db/exec-one! conn [sql:has-job-resource-refs id]) (get :has-refs))) +(def ^:private + sql:has-upload-session-chunk-refs + "SELECT EXISTS (SELECT 1 FROM upload_session_chunk WHERE object_id = ?) AS has_refs") + +(defn- has-upload-session-chunk-refs? + [conn {:keys [id]}] + (-> (db/exec-one! conn [sql:has-upload-session-chunk-refs id]) + (get :has-refs))) + (def sql:exists-file-data-refs "SELECT EXISTS ( SELECT 1 FROM file_data @@ -165,6 +174,31 @@ :id (str id)) sto/default-bucket))) +(def ^:private sql:lock-storage-objects + "SELECT id FROM storage_object WHERE id = ANY(?::uuid[]) ORDER BY id FOR UPDATE") + +(defn- verify-frozen + "Re-checks, holding the row locks, that the about-to-freeze objects are + still referenced. A concurrent purge (mapping delete plus touch) either + commits before the check and the object moves to deletion, or blocks on + the lock and its touch wins last, so no touch is ever swallowed by a + stale freeze. Ids are locked in order to avoid lock-order inversions + between workers. Returns [still-frozen-ids now-deletable-ids]." + [conn has-refs? bucket objects] + (let [by-id (into {} (map (juxt :id identity)) objects) + ids (db/create-array conn "uuid" (sort (keys by-id))) + locked (into #{} (map :id) (db/exec! conn [sql:lock-storage-objects ids]))] + (reduce (fn [[frozen deletable] {:keys [id] :as object}] + (if (and (contains? locked id) (has-refs? conn object)) + [(conj frozen id) deletable] + (do + (l/dbg :id (str id) + :status "delete-after-recheck" + :bucket bucket) + [frozen (conj deletable id)]))) + [#{} #{}] + objects))) + (defn- process-objects [conn has-refs? bucket objects] (loop [to-freeze #{} @@ -176,17 +210,24 @@ (l/dbg :id (str id) :status "freeze" :bucket bucket) - (recur (conj to-freeze id) to-delete (rest objects))) + (recur (conj to-freeze object) to-delete (rest objects))) (do (l/dbg :id (str id) :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (do - (some->> (seq to-freeze) (mark-freeze-in-bulk conn)) - (some->> (seq to-delete) (mark-delete-in-bulk conn)) - [(count to-freeze) (count to-delete)])))) + (let [[frozen deletable] (if (seq to-freeze) + (verify-frozen conn has-refs? bucket to-freeze) + [#{} #{}]) + doomed (into to-delete deletable)] + (some->> (seq frozen) (mark-freeze-in-bulk conn)) + (some->> (seq doomed) (mark-delete-in-bulk conn)) + [(count frozen) (count doomed)])))) +;; NOTE: upload-session chunks are 1:1 with their mapping and never +;; shared, but the scan owns the liveness decision anyway: a referenced +;; chunk freezes, so gc-deleted never meets a live NO ACTION reference. +;; The freeze path re-verifies under row locks (see verify-frozen). (defn- process-bucket [conn bucket objects] (cond @@ -197,7 +238,7 @@ (= bucket "profile") (process-objects conn has-profile-refs? bucket objects) (= 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/upload-session-bucket) (process-objects conn has-upload-session-chunk-refs? 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 diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index 48bd2a7344..31fb998b5d 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -13,6 +13,7 @@ [app.db :as db] [app.db.sql :as-alias sql] [app.jobs :as jobs] + [app.loggers.audit :as audit] [app.rpc.commands.files :as files] [app.rpc.commands.profile :as profile] [app.storage :as sto] @@ -20,6 +21,29 @@ (def ^:dynamic *team-deletion* false) +(def ^:dynamic *cascade* + "Accumulates the ids marked deleted by the current cascade execution, + grouped by entity. Bound by the task entry point; nil on direct calls + (e.g. objects-gc, whose purges are announced by their own procs)." + nil) + +(defn- record + [kind id] + (when (some? *cascade*) + (swap! *cascade* update kind conj id))) + +(defn- top-exists? + "The cascade marks blindly, so an unknown top id would report phantom + rows. Every queue submission and SREPL call names a real row; only + check, never change behavior: a missing row just skips the run." + [{:keys [::db/conn]} {:keys [object id]}] + (let [object (when (some? object) (keyword (name object)))] + (if (contains? #{:team :project :file :profile} object) + (some? (db/get* conn object {:id id} + {::db/remove-deleted false + ::sql/columns [:id]})) + true))) + (defmulti delete-object (fn [_ props] (:object props))) @@ -45,7 +69,7 @@ AND deleted_at IS NULL") (defmethod delete-object :file - [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] + [{:keys [::db/conn] :as cfg} {:keys [id deleted-at file-ids]}] (when-let [file (db/get* conn :file {:id id} {::db/remove-deleted false ::sql/columns [:id :is-shared]})] @@ -103,16 +127,20 @@ (not *team-deletion*)) ;; NOTE: we don't prevent file deletion on absorb operation failure (try - (db/tx-run! cfg files/absorb-library id) + (db/tx-run! cfg files/absorb-library id :file-ids file-ids) (catch Throwable cause (l/warn :hint "error on absorbing library" :file-id id :cause cause)))) - ;; Mark file change to be deleted + ;; Mark file change to be deleted, but only the rows that do not carry a + ;; date yet. A revision is born already scheduled for deletion one hour + ;; later, and rewriting its date here would push it out to the trash + ;; lifetime; the collector would still take it, just much later than it + ;; should. (db/update! conn :file-change {:deleted-at deleted-at} - {:file-id id} + {:file-id id :deleted-at nil} {::db/return-keys false}) ;; Mark file data fragment to be deleted @@ -136,7 +164,9 @@ (db/update! conn :file-tagged-object-thumbnail {:deleted-at deleted-at} {:file-id id} - {::db/return-keys false}))) + {::db/return-keys false}) + + (record :files id))) (defmethod delete-object :project [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] @@ -153,7 +183,9 @@ {::db/columns [:id :deleted-at]})] (delete-object cfg (assoc file :object :file - :deleted-at deleted-at)))) + :deleted-at deleted-at))) + + (record :projects id)) (defmethod delete-object :team [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] @@ -175,7 +207,9 @@ {::db/columns [:id :deleted-at]})] (delete-object cfg (assoc project :object :project - :deleted-at deleted-at))))) + :deleted-at deleted-at)))) + + (record :teams id)) (defmethod delete-object :profile [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] @@ -202,13 +236,30 @@ [:object [:enum :snapshot :team :project :profile :file]] [:deleted-at ::ct/inst] [:id ::sm/uuid] - [:file-id {:optional true} ::sm/uuid]]) + [:file-id {:optional true} ::sm/uuid] + [:file-ids {:optional true} [:vector ::sm/uuid]]]) (defn execute-delete-object "Plain job handler: run the delete-object multimethod on the provided - params inside a single transaction." + params inside a single transaction, tracking the cascade and reporting + it as an audit event when anything was marked." [cfg params] - (db/tx-run! cfg delete-object params)) + (db/tx-run! cfg + (fn [cfg] + (when (top-exists? cfg params) + (binding [*cascade* (atom {:teams [] :projects [] :files []})] + (delete-object cfg params) + (let [{:keys [teams projects files]} @*cascade*] + (when (or (seq teams) (seq projects) (seq files)) + (audit/submit cfg {:name "delete-object-with-cascade" + :type "action" + :props {:object (:object params) + :id (:id params) + :deleted-at (:deleted-at params) + :cascade {:team-ids teams + :project-ids projects + :file-ids files}} + :context {:triggered-by "delete-object"}})))))))) (defmethod ig/assert-key ::job-def [_ params] diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj index fb2baa1406..53c1c75c04 100644 --- a/backend/src/app/tasks/file_gc_scheduler.clj +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -30,7 +30,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}] @@ -84,4 +84,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/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/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index 1e530ea750..1fca7bf321 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -11,9 +11,11 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] + [app.config :as cf] [app.db :as db] [app.features.fdata :as fdata] [app.jobs :as jobs] + [app.loggers.audit :as audit] [app.storage :as sto] [app.tasks.delete-object :as dobj] [integrant.core :as ig])) @@ -33,6 +35,22 @@ ;; through the pool and never through the chunk transaction, so a long backlog ;; in a single proc cannot outrun the orphan lease. +(defn- submit-hard-delete-event + "Emits one hard-delete audit event for the batch of rows just purged + by this task, so a projection of the audit stream can drop its local + rows. Each GC section purges in its own chunk transactions, so each + chunk carries only its own entity key (`:team-ids`, `:project-ids` + or `:file-ids`); a single event spanning sections could never share + their transactions. Runs inside the caller transaction, next to the + deletes: if the purge aborts, the event aborts with it." + [cfg ids-key ids] + (when (contains? cf/flags :audit-log) + (audit/submit cfg + {:name "hard-delete-objects" + :type "command" + :props {ids-key (vec ids)} + :context {:triggered-by "objects-gc"}}))) + (def ^:private sql:get-upload-sessions "SELECT us.id FROM upload_session AS us @@ -191,23 +209,28 @@ [cfg ids] (try (db/tx-run! cfg - (fn [{:keys [::db/conn ::threshold ::sto/storage]}] + (fn [{:keys [::db/conn ::threshold ::sto/storage] :as cfg}] (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-teams ids threshold]) - (reduce (fn [total {:keys [id photo-id deleted-at]}] - (l/trc :obj "team" - :id (str id) - :deleted-at (ct/format-inst deleted-at)) + (let [ids (db/create-array conn "uuid" ids) + [total deleted-ids] + (->> (db/exec! conn [sql:lock-teams ids threshold]) + (reduce (fn [[total deleted-ids] {:keys [id photo-id deleted-at]}] + (l/trc :obj "team" + :id (str id) + :deleted-at (ct/format-inst deleted-at)) - ;; Mark as deleted the storage object - (some->> photo-id (sto/touch-object! storage)) + ;; Mark as deleted the storage object + (some->> photo-id (sto/touch-object! storage)) - ;; And finally, permanently delete the team. - (let [affected (-> (db/delete! conn :team {:id id}) - (db/get-update-count))] - (+ total affected))) - 0))))) + ;; And finally, permanently delete the team. + (let [affected (-> (db/delete! conn :team {:id id}) + (db/get-update-count))] + [(+ total affected) + (cond-> deleted-ids (pos? affected) (conj id))])) + [0 []]))] + (when (seq deleted-ids) + (submit-hard-delete-event cfg :team-ids deleted-ids)) + total))) (catch Throwable cause (l/err :obj "team" :hint "error on chunk deletion, skipping it" @@ -305,20 +328,25 @@ [cfg ids] (try (db/tx-run! cfg - (fn [{:keys [::db/conn ::threshold]}] + (fn [{:keys [::db/conn ::threshold] :as cfg}] (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-projects ids threshold]) - (reduce (fn [total {:keys [id team-id deleted-at]}] - (l/trc :obj "project" - :id (str id) - :team-id (str team-id) - :deleted-at (ct/format-inst deleted-at)) + (let [ids (db/create-array conn "uuid" ids) + [total deleted-ids] + (->> (db/exec! conn [sql:lock-projects ids threshold]) + (reduce (fn [[total deleted-ids] {:keys [id team-id deleted-at]}] + (l/trc :obj "project" + :id (str id) + :team-id (str team-id) + :deleted-at (ct/format-inst deleted-at)) - (let [affected (-> (db/delete! conn :project {:id id}) - (db/get-update-count))] - (+ total affected))) - 0))))) + (let [affected (-> (db/delete! conn :project {:id id}) + (db/get-update-count))] + [(+ total affected) + (cond-> deleted-ids (pos? affected) (conj id))])) + [0 []]))] + (when (seq deleted-ids) + (submit-hard-delete-event cfg :project-ids deleted-ids)) + total))) (catch Throwable cause (l/err :obj "project" :hint "error on chunk deletion, skipping it" @@ -357,20 +385,25 @@ [cfg ids] (try (db/tx-run! cfg - (fn [{:keys [::db/conn ::threshold]}] + (fn [{:keys [::db/conn ::threshold] :as cfg}] (db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"]) - (let [ids (db/create-array conn "uuid" ids)] - (->> (db/exec! conn [sql:lock-files ids threshold]) - (reduce (fn [total {:keys [id deleted-at project-id]}] - (l/trc :obj "file" - :id (str id) - :project-id (str project-id) - :deleted-at (ct/format-inst deleted-at)) + (let [ids (db/create-array conn "uuid" ids) + [total deleted-ids] + (->> (db/exec! conn [sql:lock-files ids threshold]) + (reduce (fn [[total deleted-ids] {:keys [id deleted-at project-id]}] + (l/trc :obj "file" + :id (str id) + :project-id (str project-id) + :deleted-at (ct/format-inst deleted-at)) - (let [affected (-> (db/delete! conn :file {:id id}) - (db/get-update-count))] - (+ total affected))) - 0))))) + (let [affected (-> (db/delete! conn :file {:id id}) + (db/get-update-count))] + [(+ total affected) + (cond-> deleted-ids (pos? affected) (conj id))])) + [0 []]))] + (when (seq deleted-ids) + (submit-hard-delete-event cfg :file-ids deleted-ids)) + total))) (catch Throwable cause (l/err :obj "file" :hint "error on chunk deletion, skipping it" 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/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 50029746e3..05cd056d92 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 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 + (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 consumer 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/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/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/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/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj index f992f155d2..12cd353a8d 100644 --- a/backend/test/backend_tests/binfile_svg_test.clj +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -195,15 +195,15 @@ :project-id (:default-project-id profile) :is-shared false}) [svg-path svg-size] (write-temp-svg clean-svg) - out (th/command! {::th/type :upload-file-media-object - ::rpc/profile-id (:id profile) - :file-id (:id file) - :is-local true - :name "benign.svg" - :content {:filename "benign.svg" - :path svg-path - :mtype "image/svg+xml" - :size svg-size}}) + out (th/multipart-command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id profile) + :file-id (:id file) + :is-local true + :name "benign.svg" + :content {:filename "benign.svg" + :path svg-path + :mtype "image/svg+xml" + :size svg-size}}) _ (t/is (nil? (:error out)) (str "upload failed: " (pr-str (:error out)))) uploaded (:result out) page-id (first (get-in file [:data :pages])) @@ -375,13 +375,14 @@ of its single media object." [profile file] (let [bundle (export-v1 profile file)] - (let [result (-> th/*system* - (assoc ::bfc/project-id (:default-project-id profile)) - (assoc ::bfc/profile-id (:id profile)) - (assoc ::bfc/input (io/input-stream bundle)) - (v1/import-files!))] - (t/is (= 1 (count result))) - (let [rows (th/db-query :file-media-object {:file-id (first result)}) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input (io/input-stream bundle)) + (v1/import-files!)) + file-id (first (:file-ids result))] + (t/is (= 1 (count (:file-ids result)))) + (let [rows (th/db-query :file-media-object {:file-id file-id}) storage (:app.storage/storage th/*system*)] (t/is (= 1 (count rows))) (let [object (sto/get-object storage (:media-id (first rows)))] diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index a323964e2b..505e46cfe1 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -12,17 +12,18 @@ [app.binfile.v3 :as v3] [app.common.data :as d] [app.common.features :as cfeat] + [app.common.files.changes-builder :as pcb] + [app.common.files.tokens :as cfo] [app.common.files.validate :as cfv] - [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.shape :as cts] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] - [app.db.sql :as sql] - [app.http :as http] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [app.storage :as sto] @@ -36,7 +37,6 @@ [datoteka.io :as io]) (:import java.io.ByteArrayInputStream - java.io.DataInputStream java.io.OutputStreamWriter java.io.Writer java.util.zip.Deflater @@ -105,6 +105,117 @@ (dissoc file :data)))) +(defn- prepare-file-with-tokens-lib + "A shared library and a linked file with different active token sets. + The file has a rect with two applied tokens. Returns the created files, + and the ids and tokens needed to check them." + [profile] + (let [lib-page-id (uuid/custom 5 1) + file-page-id (uuid/custom 5 2) + shape-id (uuid/custom 5 3) + set-id (uuid/custom 5 4) + theme-id (uuid/custom 5 5) + other-set-id (uuid/custom 5 8) + other-theme-id (uuid/custom 5 9) + + token-radius (ctob/make-token :id (uuid/custom 5 6) + :name "token-radius" + :type :border-radius + :value 10) + + token-opacity (ctob/make-token :id (uuid/custom 5 7) + :name "token-opacity" + :type :opacity + :value 0.7) + + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id + :name "test-token-set")) + (ctob/add-theme (ctob/make-token-theme :id theme-id + :name "test-theme" + :sets #{"test-token-set"})) + (ctob/add-set (ctob/make-token-set :id other-set-id + :name "other-token-set")) + (ctob/add-theme (ctob/make-token-theme :id other-theme-id + :name "other-theme" + :sets #{"other-token-set"})) + (ctob/add-token other-set-id (ctob/make-token :id (uuid/custom 5 10) + :name "other-token" + :type :opacity + :value 0.4)) + (ctob/add-token set-id token-radius) + (ctob/add-token set-id token-opacity)) + + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Tokens Library"}) + + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (th/link-file-to-library* {:file-id (:id file) + :library-id (:id library)}) + + (update-file! + :file-id (:id library) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data library)) + (pcb/add-empty-page lib-page-id "library page") + (pcb/set-tokens-lib tokens-lib) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{other-theme-id} + :active-set-ids #{other-set-id})) + (:redo-changes))) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data file)) + (pcb/add-empty-page file-page-id "file page") + (pcb/set-tokens-source (:id library)) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})) + (:redo-changes))) + + (let [page (-> (bfc/get-file th/*system* (:id file) {:realize? true}) + (get-in [:data :pages-index file-page-id])) + shape (as-> (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect}) $ + (cto/apply-token-to-shape {:shape $ + :token token-radius + :attributes [:r1 :r2 :r3 :r4]}) + (cto/apply-token-to-shape {:shape $ + :token token-opacity + :attributes [:opacity]}))] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-page page) + (pcb/with-objects (:objects page)) + (pcb/add-object shape) + (:redo-changes)))) + + {:library library + :file file + :page-id file-page-id + :shape-id shape-id + :token-radius token-radius + :token-opacity token-opacity})) + (def ^:private svg-raw-page-id (uuid/custom 1 1)) (def ^:private svg-raw-root-id (uuid/custom 3 1)) (def ^:private svg-raw-child-id (uuid/custom 3 2)) @@ -376,6 +487,299 @@ (t/is (= "icons-library" (:slug ext-lib))) (t/is (= [(:id file)] (:used-by ext-lib)))))))) +(defn- zip-entry-names + [path] + (with-open [zin (ZipFile. (fs/file path))] + (into #{} (map #(.getName ^ZipEntry %)) (iterator-seq (.entries zin))))) + +(t/deftest export-detach-libraries-removes-tokens-library-although-keeps-applied-tokens + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile)] + + ;; Precondition: before the export, the file really depends on the + ;; tokens of the library + (let [file-data (:data (bfc/get-file th/*system* (:id file)))] + (t/is (= (:id library) (cfo/get-tokens-source file-data))) + (t/is (some? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))) + + ;; Do the export, with type :detach-libraries + (let [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)) + + ;; Only the file is exported, not the library + (let [manifest (v3/get-manifest th/*system* output) + names (zip-entry-names output)] + (t/is (= [(:id file)] (mapv :id (:files manifest)))) + (t/is (empty? (:relations manifest))) + (t/is (empty? (:external-libraries manifest))) + (t/is (contains? names (str "files/" (:id file) ".json"))) + (t/is (not-any? #(str/includes? % (str (:id library))) names))) + + ;; The exported file has neither tokens library nor tokens status + (let [names (zip-entry-names output)] + (t/is (not (contains? names (str "files/" (:id file) "/tokens.json")))) + (t/is (not (contains? names (str "files/" (:id file) "/tokens-status.json"))))) + + ;; Import the file again + (let [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 output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; ...so the imported file has no tokens source, status or library + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data))) + + ;; The shape keeps the applied tokens + (t/is (some? shape)) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity])))))) + +(t/deftest export-merge-libraries-embeds-tokens-library-and-keeps-applied-tokens + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile)] + + ;; Precondition: before the export, the file depends on the tokens + ;; of the library and has no tokens library of its own + (let [file-data (:data (bfc/get-file th/*system* (:id file)))] + (t/is (= (:id library) (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))) + + (let [library-tokens-lib (-> (bfc/get-file th/*system* (:id library)) + (:data) + (cfo/get-tokens-lib)) + output (tmp/tempfile :suffix ".zip")] + + (t/is (some? library-tokens-lib)) + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :merge-libraries)) + (io/output-stream output)) + + ;; Only the file is exported, not the library + (let [manifest (v3/get-manifest th/*system* output) + names (zip-entry-names output)] + (t/is (= [(:id file)] (mapv :id (:files manifest)))) + (t/is (empty? (:relations manifest))) + (t/is (empty? (:external-libraries manifest))) + (t/is (contains? names (str "files/" (:id file) ".json"))) + (t/is (not-any? #(str/includes? % (str (:id library))) names))) + + ;; The exported file carries its own tokens library + (t/is (contains? (zip-entry-names output) + (str "files/" (:id file) "/tokens.json"))) + + (let [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 output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; The tokens library of the library file is now in the file... + ;; (the import gives new ids to the sets and tokens, so compare + ;; their content) + (t/is (some? (cfo/get-tokens-lib file-data))) + (t/is (= (ctob/export-dtcg-json library-tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + + ;; ...so the file is its own tokens source + (t/is (nil? (cfo/get-tokens-source file-data))) + + ;; Activation follows the consuming file, using the imported ids. + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))) + (t/is (= #{"token-radius" "token-opacity"} + (set (keys (cfo/get-tokens-in-active-sets + (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))))) + + ;; The shape keeps the applied tokens + (t/is (some? shape)) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity])))))) + +(t/deftest export-detach-libraries-preserves-own-tokens-and-status + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile) + file-data (:data (bfc/get-file th/*system* (:id file))) + library-data (:data (bfc/get-file th/*system* (:id library))) + tokens-lib (cfo/get-tokens-lib library-data) + output (tmp/tempfile :suffix ".zip")] + + ;; Keep the linked library, but use the file's own tokens. + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data file-data) + (pcb/set-tokens-source nil) + (pcb/set-tokens-lib tokens-lib) + (:redo-changes))) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :detach-libraries) + (io/output-stream output)) + + (let [names (zip-entry-names output)] + (t/is (contains? names (str "files/" (:id file) "/tokens.json"))) + (t/is (contains? names (str "files/" (:id file) "/tokens-status.json")))) + + (let [result (v3/import-files! + (assoc th/*system* + ::bfc/project-id (:default-project-id profile) + ::bfc/team-id (:default-team-id profile) + ::bfc/profile-id (:id profile) + ::bfc/input output)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (some? (cfo/get-tokens-status file-data))) + (when-let [status (cfo/get-tokens-status file-data)] + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names status (cfo/get-tokens-lib file-data))))) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity]))))) + +(defn- import-output + "Import the exported output in the default project of the profile and + return the imported files, indexed by name." + [profile output] + (let [result (v3/import-files! + (assoc th/*system* + ::bfc/project-id (:default-project-id profile) + ::bfc/team-id (:default-team-id profile) + ::bfc/profile-id (:id profile) + ::bfc/input output))] + (into {} + (map (fn [id] + (let [file (bfc/get-file th/*system* id)] + [(:name file) file]))) + (:file-ids result)))) + +(t/deftest export-include-libraries-keeps-tokens-status-of-the-file + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (let [files (import-output profile output) + library' (get files (:name library)) + file' (get files (:name file)) + file-data (:data file') + library-lib (cfo/get-tokens-lib (:data library')) + file-status (cfo/get-tokens-status file-data)] + + (t/is (= 2 (count files))) + (t/is (= (:id library') (cfo/get-tokens-source file-data))) + + ;; The active sets of the file are resolved against the sets of the + ;; imported library, so they are not lost. + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names file-status library-lib))) + (t/is (= #{"token-radius" "token-opacity"} + (set (keys (cfo/get-tokens-in-active-sets file-status library-lib)))))))) + +(t/deftest export-file-with-deleted-tokens-library-embeds-its-tokens + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + tokens-lib (-> (bfc/get-file th/*system* (:id library)) + (:data) + (cfo/get-tokens-lib)) + output (tmp/tempfile :suffix ".zip")] + + ;; The state left by deleting the library: in the trash and unlinked, + ;; but still the tokens source of the file. + (th/db-update! :file {:deleted-at (ct/in-future {:days 7})} {:id (:id library)}) + (th/db-delete! :file-library-rel {:library-file-id (:id library)}) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (t/is (contains? (zip-entry-names output) + (str "files/" (:id file) "/tokens.json"))) + + (let [files (import-output profile output) + file-data (:data (get files (:name file)))] + (t/is (= 1 (count files))) + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data))))))) + +(t/deftest export-file-with-missing-tokens-library-removes-tokens-source + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + output (tmp/tempfile :suffix ".zip")] + + ;; The tokens source points to a library that does not exist anymore + (th/db-delete! :file-library-rel {:library-file-id (:id library)}) + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data (bfc/get-file th/*system* (:id file)))) + (pcb/set-tokens-source (uuid/next)) + (:redo-changes))) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (let [files (import-output profile output) + file-data (:data (get files (:name file)))] + (t/is (= 1 (count files))) + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))))) + (t/deftest import-auto-links-single-candidate (let [profile (th/create-profile* 1) ;; Create a shared library file @@ -2246,24 +2650,28 @@ (t/is (= :validation (:type out))) (t/is (= :max-file-size-reached (:code out)))))) -(t/deftest import-configures-finite-transaction-idle-timeout - ;; The binfile import transaction must set a finite ceiling for - ;; idle_in_transaction_session_timeout (20 min) instead of disabling - ;; it entirely: a stalled import must not retain a pool connection - ;; without any upper bound. The ceiling must be scoped with SET - ;; LOCAL: once the transaction ends, the pool session default must - ;; be restored for subsequent transactions. - (let [pg-setting-sql ["SELECT setting FROM pg_settings WHERE name = 'idle_in_transaction_session_timeout'"] - in-tx (db/tx-run! th/*system* - (fn [cfg] - (bfc/configure-database-timeouts! cfg) - (:setting (db/exec-one! cfg pg-setting-sql)))) - ;; SET LOCAL must not leak past the transaction boundary - after (db/tx-run! th/*system* - (fn [cfg] - (:setting (db/exec-one! cfg pg-setting-sql))))] - (t/is (= "1200000" in-tx)) - (t/is (= "300000" after)))) +(def ^:private sql:current-idle-timeout + "SELECT current_setting('idle_in_transaction_session_timeout')::interval + = interval '30 minutes' AS ok") + +(t/deftest binfile-transactions-use-a-finite-idle-timeout + ;; The binfile import and export transactions must set a finite + ;; ceiling for idle_in_transaction_session_timeout (30 min) instead + ;; of disabling it entirely: a stalled transaction must not retain a + ;; pool connection without any upper bound. The ceiling is scoped + ;; with SET LOCAL: once the transaction ends, the pool session + ;; default is restored for subsequent transactions. + (db/tx-run! th/*system* + (fn [{::db/keys [conn] :as cfg}] + (t/testing "export transaction" + (bfc/set-transaction-idle-timeout cfg) + (t/is (true? (-> (db/exec-one! conn [sql:current-idle-timeout]) + :ok)))) + + (t/testing "import transaction" + (bfc/configure-import-transaction cfg) + (t/is (true? (-> (db/exec-one! conn [sql:current-idle-timeout]) + :ok))))))) (def ^:private index-test-file-id "22222222-2222-2222-2222-222222222222") (def ^:private index-test-page-id "44444444-4444-4444-4444-444444444444") diff --git a/backend/test/backend_tests/climit_test.clj b/backend/test/backend_tests/climit_test.clj index a7c89cb38f..b5cef6bda3 100644 --- a/backend/test/backend_tests/climit_test.clj +++ b/backend/test/backend_tests/climit_test.clj @@ -663,7 +663,6 @@ (fn [] (#'climit/republish-waiting (th/metrics-stub) counters) (t/is (= [3] (waiting-value-of name))))))) - (t/deftest halting-the-component-stops-the-republisher ;; The republisher is a daemon thread started with the component: halting it ;; has to stop the thread, or every restart leaves another one running. diff --git a/backend/test/backend_tests/delete_object_test.clj b/backend/test/backend_tests/delete_object_test.clj new file mode 100644 index 0000000000..878bea305d --- /dev/null +++ b/backend/test/backend_tests/delete_object_test.clj @@ -0,0 +1,107 @@ +;; 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.delete-object-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- submitted-events + [audit-mock] + (->> (:call-args-list @audit-mock) + (map second))) + +(defn- cascade-events + [audit-mock] + (filter #(= "delete-object-with-cascade" (:name %)) + (submitted-events audit-mock))) + +(t/deftest delete-object-team-cascade-emits-event + ;; The cascade marks the team, its projects and their files; the event + ;; carries every affected id so a projection can follow without reading + ;; production tables. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 91 {:profile-id (:id profile)}) + project (th/create-project* 91 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 91 {:profile-id (:id profile) + :project-id (:id project)}) + deleted-at (ct/now)] + (th/run-task! :delete-object {:object :team + :id (:id team) + :deleted-at deleted-at}) + (t/is (some? (:deleted-at (th/db-get :team {:id (:id team)} + {::db/remove-deleted false})))) + (t/is (some? (:deleted-at (th/db-get :project {:id (:id project)} + {::db/remove-deleted false})))) + (t/is (some? (:deleted-at (th/db-get :file {:id (:id file)} + {::db/remove-deleted false})))) + (let [events (cascade-events audit-mock)] + (t/is (= 1 (count events))) + (t/is (= "action" (:type (first events)))) + (let [props (:props (first events))] + (t/is (= "team" (name (:object props)))) + (t/is (= (:id team) (:id props))) + (t/is (= deleted-at (:deleted-at props))) + (t/is (not (contains? props :team-ids))) + (t/is (not (contains? props :project-ids))) + (t/is (not (contains? props :file-ids))) + (t/is (= #{(:id team)} (set (get-in props [:cascade :team-ids])))) + (t/is (= #{(:default-project-id team) (:id project)} + (set (get-in props [:cascade :project-ids])))) + (t/is (= #{(:id file)} (set (get-in props [:cascade :file-ids])))))))))) + +(t/deftest delete-object-file-cascade-emits-event + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 92 {:profile-id (:id profile)}) + project (th/create-project* 92 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 92 {:profile-id (:id profile) + :project-id (:id project)}) + deleted-at (ct/now)] + (th/run-task! :delete-object {:object :file + :id (:id file) + :deleted-at deleted-at}) + (let [events (cascade-events audit-mock)] + (t/is (= 1 (count events))) + (t/is (= "action" (:type (first events)))) + (let [props (:props (first events))] + (t/is (= "file" (name (:object props)))) + (t/is (= (:id file) (:id props))) + (t/is (= [] (get-in props [:cascade :team-ids]))) + (t/is (= [] (get-in props [:cascade :project-ids]))) + (t/is (= [(:id file)] (get-in props [:cascade :file-ids]))))))))) + +(t/deftest delete-object-missing-row-emits-nothing + ;; A cascade over an unknown id marks nothing, so there is nothing to report. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (th/run-task! :delete-object {:object :team + :id (uuid/next) + :deleted-at (ct/now)}) + (t/is (empty? (:call-args-list @audit-mock)))))) + +(t/deftest delete-object-snapshot-emits-nothing + ;; Snapshots have no projection table; their cascade stays silent. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (th/run-task! :delete-object {:object :snapshot + :id (uuid/next) + :file-id (uuid/next) + :deleted-at (ct/now)}) + (t/is (empty? (:call-args-list @audit-mock)))))) diff --git a/backend/test/backend_tests/file_change_retention_test.clj b/backend/test/backend_tests/file_change_retention_test.clj new file mode 100644 index 0000000000..7cede5a164 --- /dev/null +++ b/backend/test/backend_tests/file_change_retention_test.clj @@ -0,0 +1,209 @@ +;; 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 backend-tests.file-change-retention-test + "Retention of `file_change` rows through the file lifecycle. + + A file has two kinds of row. Durable history: the snapshots, which a restore + brings back. And ephemeral revisions: the xlog of an edit, born already + scheduled for deletion one hour later, which nobody needs once the sessions + that were going to read them are gone. + + The invariant under test is that a deletion date is assigned once and never + moved: restoring a file must not unmark a revision, and deleting a file must + not push a revision's date out to the trash lifetime. When either happens the + revision becomes invisible to `objects-gc`, which only looks at rows that + carry a date, and it stays in the table for ever." + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.commands.files :as files] + [app.util.blob :as blob] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private test-now + "The instant every test pins the clock to. Whole seconds on purpose: a value + with a sub-second part does not survive the round trip through the database, + and the test could not compare it for equality." + (ct/inst "2026-10-01T00:00:00Z")) + +(def ^:private revision-lifetime + "The lifetime the `update-file` flow gives a revision when it inserts it." + (ct/duration {:hours 1})) + +(defn- create-file + [i] + (let [profile (th/create-profile* i {:is-active true})] + [profile (th/create-file* i {:profile-id (:id profile) + :project-id (:default-project-id profile)})])) + +(defn- insert-change + "Inserts a `file_change` row directly, so the test builds the exact shape it + needs. The row class is decided by what the caller passes: a revision carries + only `changes`, a snapshot carries `label`, and a legacy payload row carries + `data` without a label. Returns the new row id." + [file-id {:keys [label data changes created-at deleted-at created-by]}] + (let [created-at (or created-at (ct/now)) + id (uuid/next)] + (th/db-insert! :file-change + {:id id + :file-id file-id + :created-at created-at + :updated-at created-at + :deleted-at deleted-at + :revn 1 + :version 0 + :created-by (or created-by "system") + :label label + :data data + :changes changes} + {::db/return-keys false}) + id)) + +(defn- insert-revision + "A revision exactly as the `update-file` flow writes it: no label, no data and + `changes` filled, already scheduled for deletion one hour after creation." + [file-id created-at] + (insert-change file-id {:created-at created-at + :changes (blob/encode {:foo "bar"}) + :deleted-at (ct/plus created-at revision-lifetime)})) + +(defn- find-change + [id] + (th/db-get :file-change {:id id} {::db/remove-deleted false})) + +(defn- find-file + [id] + (th/db-get :file {:id id} {::db/remove-deleted false})) + +(defn- restore-file + [file-id] + (db/tx-run! th/*system* + (fn [{:keys [::db/conn]}] + (#'files/restore-files conn [file-id])))) + +(t/deftest restoring-a-file-leaves-the-revision-schedule-alone + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[_ file] (create-file 1) + file-id (:id file) + trash-at (ct/in-future {:days 7}) + old-at (ct/in-past {:hours 2}) + + revision-id (insert-revision file-id old-at) + revision-at (:deleted-at (find-change revision-id)) + + snapshot-id (insert-change file-id {:created-at old-at + :created-by "user" + :label "a snapshot" + :deleted-at trash-at}) + + legacy-id (insert-change file-id {:created-at old-at + :data (blob/encode {:legacy true}) + :deleted-at trash-at})] + + (restore-file file-id) + + (t/testing "the revision keeps the schedule it was born with" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (t/testing "the snapshot comes back" + (t/is (nil? (:deleted-at (find-change snapshot-id))))) + + (t/testing "the legacy payload row comes back" + (t/is (nil? (:deleted-at (find-change legacy-id)))))))) + +(t/deftest deleting-a-file-does-not-push-the-revision-schedule-out + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[_ file] (create-file 1) + file-id (:id file) + trash-at (ct/in-future {:days 7}) + old-at (ct/in-past {:hours 2}) + system-at (ct/in-future {:days 2}) + + revision-id (insert-revision file-id old-at) + revision-at (:deleted-at (find-change revision-id)) + + snapshot-id (insert-change file-id {:created-at old-at + :created-by "user" + :label "a snapshot"}) + + system-id (insert-change file-id {:created-at old-at + :label "internal/snapshot/1" + :deleted-at system-at})] + + (th/run-task! :delete-object {:object :file + :deleted-at trash-at + :id file-id}) + + (t/testing "the revision keeps the schedule it was born with" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (t/testing "the snapshot takes the file's deletion date" + (t/is (= (:deleted-at (find-file file-id)) + (:deleted-at (find-change snapshot-id))))) + + (t/testing "the system snapshot keeps the date it already had" + (t/is (= system-at (:deleted-at (find-change system-id)))))))) + +(t/deftest updating-a-snapshot-cannot-reach-a-revision + ;; The snapshot paths clear the deletion mark of the row they touch, which is + ;; only legitimate for durable history. They reach a row through + ;; `sql:snapshots`, which filters `label IS NOT NULL`, and a revision never + ;; carries one. This test pins that: the row has to keep the date it was born + ;; with, and the RPC has to reject the id. If a future change loosens that + ;; read filter, this goes red instead of the revision losing its date in + ;; silence. + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[profile file] (create-file 1) + file-id (:id file) + revision-id (insert-revision file-id (ct/in-past {:hours 2})) + revision-at (:deleted-at (find-change revision-id)) + + out (th/command! {::th/type :update-file-snapshot + ::rpc/profile-id (:id profile) + :id revision-id + :label "renamed"})] + + (t/testing "the RPC rejects the id" + (t/is (th/ex-info? (:error out))) + (t/is (= :not-found (:type (ex-data (:error out)))))) + + (t/testing "and the revision keeps the date it was born with" + (t/is (= revision-at (:deleted-at (find-change revision-id)))))))) + +(t/deftest a-deleted-then-restored-file-does-not-leak-its-revisions + ;; The lifecycle that leaked 358,517 rows in production: a file is edited, it + ;; ends up in the trash, it comes back, and the revisions that were scheduled + ;; to die an hour after they were written have to die anyway. + (binding [ct/*clock* (ct/fixed-clock test-now)] + (let [[_ file] (create-file 1) + file-id (:id file) + + revision-id (insert-revision file-id (ct/in-past {:hours 2})) + revision-at (:deleted-at (find-change revision-id))] + + (th/run-task! :delete-object {:object :file + :deleted-at (ct/in-future {:days 7}) + :id file-id}) + + (t/testing "deleting the file leaves the revision schedule alone" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (restore-file file-id) + + (t/testing "restoring the file leaves it alone too" + (t/is (= revision-at (:deleted-at (find-change revision-id))))) + + (th/run-task! :objects-gc {}) + + (t/testing "so the collector takes it" + (t/is (nil? (find-change revision-id))))))) 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/files_update_validation_timing_test.clj b/backend/test/backend_tests/files_update_validation_timing_test.clj new file mode 100644 index 0000000000..4c07e5ec8f --- /dev/null +++ b/backend/test/backend_tests/files_update_validation_timing_test.clj @@ -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 INC Sucursal en España SL + +(ns backend-tests.files-update-validation-timing-test + (:require + [app.binfile.common :as bfc] + [app.common.files.validate :as val] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main :as main] + [app.metrics :as mtx] + [app.metrics.definition :as-alias mdef] + [app.rpc.commands.files-update :as files-update] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.CollectorRegistry)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- bucket-value + [metrics mname label-names label-values le] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + (str mname "_bucket") + (into-array String (conj (vec label-names) "le")) + (into-array String (conj (vec label-values) le)))) + +(defn- test-file + [] + {:id (uuid/next) + :revn 0 + :version 0 + :data {}}) + +(defn- test-cfg + [metrics] + {::mtx/metrics metrics + ::files-update/validation-timings (atom {})}) + +(def ^:private all-flags + #{:file-validation + :file-schema-validation + :soft-file-validation + :soft-file-schema-validation}) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest validation-timing-metric-uses-request-buckets + (t/is (= mtx/default-request-histogram-buckets + (get-in main/default-metrics [:update-file-validation-timing ::mdef/buckets]))) + (t/is (= "penpot_rpc_update_file_validation_timing" + (get-in main/default-metrics [:update-file-validation-timing ::mdef/name])))) + +(t/deftest process-changes-and-validate-records-all-steps + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + cfg (test-cfg metrics)] + (binding [cf/flags all-flags] + (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {}) + val/validate-file-affected! (fn [_ _ _] nil) + val/validate-file-schema! (fn [_] nil)] + (let [[file] (@#'files-update/process-changes-and-validate cfg (test-file) [] nil) + timings @(::files-update/validation-timings cfg)] + (t/is (map? file)) + (t/is (number? (:resolve-libs-ms timings))) + (t/is (number? (:process-changes-ms timings))) + (t/is (number? (:soft-validate-affected-ms timings))) + (t/is (number? (:soft-validate-schema-ms timings))) + (t/is (number? (:validate-affected-ms timings))) + (t/is (number? (:validate-schema-ms timings))) + (doseq [step ["resolve-libs" "process-changes" + "soft-validate-affected" "soft-validate-schema" + "validate-affected" "validate-schema"]] + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" + ["step"] [step] "60000.0")) + (str "expected one sample for step " step)))))))) + +(t/deftest process-changes-and-validate-skips-when-skip-validate + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + cfg (test-cfg metrics)] + (binding [cf/flags all-flags] + (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] + (throw (ex-info "must not resolve libs" {}))) + val/validate-file-affected! (fn [_ _ _] + (throw (ex-info "must not validate" {}))) + val/validate-file-schema! (fn [_] + (throw (ex-info "must not validate schema" {})))] + ;; soft validations still run: they only check flags, not skip-validate + (let [[file] (@#'files-update/process-changes-and-validate cfg (test-file) [] true) + timings @(::files-update/validation-timings cfg)] + (t/is (map? file)) + (t/is (nil? (:resolve-libs-ms timings))) + (t/is (number? (:process-changes-ms timings))) + (t/is (number? (:soft-validate-affected-ms timings))) + (t/is (number? (:soft-validate-schema-ms timings))) + (t/is (nil? (:validate-affected-ms timings))) + (t/is (nil? (:validate-schema-ms timings))) + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" + ["step"] ["process-changes"] "60000.0")))))))) + +(t/deftest process-changes-and-validate-records-timing-on-error + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + cfg (test-cfg metrics)] + (binding [cf/flags all-flags] + (with-redefs [bfc/get-resolved-file-libraries (fn [_ _] {}) + val/validate-file-affected! (fn [_ _ _] + (throw (ex-info "bad file" {:code :referential-integrity}))) + val/validate-file-schema! (fn [_] nil)] + (let [timings-atom (::files-update/validation-timings cfg)] + (t/is (thrown? clojure.lang.ExceptionInfo + (@#'files-update/process-changes-and-validate cfg (test-file) [] nil))) + ;; the failing step still recorded its timing via finally + (t/is (number? (:validate-affected-ms @timings-atom))) + (t/is (number? (:resolve-libs-ms @timings-atom))) + (t/is (number? (:process-changes-ms @timings-atom)))))))) + +(t/deftest record-validation-timing-tolerates-missing-collaborators + ;; production code must not blow up when called without metrics + ;; (e.g. scripts) or without the timings collector. + (let [record @#'files-update/record-validation-timing] + (t/is (nil? (record {} :resolve-libs-ms "resolve-libs" 12))) + (t/is (nil? (record {::mtx/metrics nil} :resolve-libs-ms "resolve-libs" 12))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 7b5f5aeed8..5daac2befb 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -149,6 +149,7 @@ :app.notifications/handler :app.loggers.mattermost/reporter :app.loggers.database/reporter + :app.loggers.metrics/reporter :app.worker/cron :app.worker/dispatcher) (remove-job-runners)) @@ -252,6 +253,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)) @@ -493,6 +518,13 @@ (-> params (assoc :app.rpc/request-at (ct/now)) + ;; Mirror of the production flag in `app.rpc/make-rpc-handler`: + ;; upload maps are only trusted over multipart. Tests declare it + ;; with a content-type header on the supplied request map. + (assoc ::rpc/is-multipart + (str/starts-with? + (str (yreq/get-header request "content-type")) + "multipart/")) (with-meta {:app.http/request request})))) (defn command! @@ -506,6 +538,20 @@ (let [params (prepare-rpc-params data)] (try-on! (method-fn params))))) +(defn command-raw! + "Like `command!`, but returns the raw result without unwrapping it, so a test + can read the result metadata (for example the audit props a command sets on a + nil result). The HTTP layer unwraps it the same way in production." + [{:keys [::type] :as data}] + (let [[_ method-fn] (get-in *system* [:app.rpc/methods type])] + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "rpc method '%' not found" (name type)))) + + (let [params (prepare-rpc-params data)] + (method-fn params)))) + (defn management-command! [{:keys [::type] :as data}] (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])] @@ -516,6 +562,33 @@ (let [params (prepare-rpc-params data)] (try-on! (method-fn params))))) +(def multipart-headers + {"content-type" "multipart/form-data; boundary=test"}) + +(defn with-multipart + "Attach a multipart content-type to RPC test params, declaring that the + upload maps they carry arrived over multipart (as the production + multipart parser would deliver them). Preserves any request metadata + already present on the data." + [data] + (let [request (-> data meta :app.http/request)] + (vary-meta data assoc :app.http/request + (assoc (if (map? request) request {}) + :headers (merge multipart-headers + (when (map? request) + (:headers request))))))) + +(defn multipart-command! + "Like `command!` but declares the request as multipart, for calls that + carry upload maps (`:file`, `:content`, `:media`)." + [data] + (command! (with-multipart data))) + +(defn multipart-management-command! + "Like `management-command!` but declares the request as multipart." + [data] + (management-command! (with-multipart data))) + (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." diff --git a/backend/test/backend_tests/helpers_test.clj b/backend/test/backend_tests/helpers_test.clj index d8e1bb2dc2..2473f86115 100644 --- a/backend/test/backend_tests/helpers_test.clj +++ b/backend/test/backend_tests/helpers_test.clj @@ -91,6 +91,35 @@ (t/deftest make-dummy-request-missing-cookie-returns-nil-value (t/is (= {:value nil} (yreq/get-cookie (th/make-dummy-request) "missing")))) +;; --- RPC PARAMS: MULTIPART FLAG + +(t/deftest prepare-rpc-params-marks-multipart-request + (let [params (#'th/prepare-rpc-params + (with-meta {::th/type :dummy + :events [{:name "event"}]} + {:app.http/request {:headers {"content-type" "multipart/form-data; boundary=xxx"}}}))] + (t/is (true? (:app.rpc/is-multipart params))))) + +(t/deftest prepare-rpc-params-leaves-non-multipart-request-unmarked + (let [params (#'th/prepare-rpc-params + (with-meta {::th/type :dummy + :events [{:name "event"}]} + {:app.http/request {:headers {"content-type" "application/transit+json"}}}))] + (t/is (false? (:app.rpc/is-multipart params))))) + +(t/deftest prepare-rpc-params-leaves-missing-content-type-unmarked + (let [params (#'th/prepare-rpc-params {::th/type :dummy + :events [{:name "event"}]})] + (t/is (false? (:app.rpc/is-multipart params))))) + +(t/deftest prepare-rpc-params-body-cannot-forge-multipart-flag + (let [params (#'th/prepare-rpc-params + (with-meta {::th/type :dummy + :app.rpc/is-multipart true + :events [{:name "event"}]} + {:app.http/request {:headers {"content-type" "application/json"}}}))] + (t/is (false? (:app.rpc/is-multipart params))))) + ;; --- RPC PARAMS: REQUEST METADATA (t/deftest prepare-rpc-params-preserves-supplied-request diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index c00ae08ce1..fc4b58e5a4 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -7,6 +7,7 @@ (ns backend-tests.http-middleware-test (:require [app.common.exceptions :as ex] + [app.common.json :as json] [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] @@ -16,9 +17,11 @@ [app.http.errors :as http-errors] [app.http.middleware :as mw] [app.http.session :as session] - [app.main :as-alias main] + [app.main :as main] + [app.metrics :as mtx] [app.rpc :as-alias rpc] [app.rpc.commands.access-token] + [app.storage.schema :as stsch] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.string :as str] @@ -28,6 +31,7 @@ [yetti.request :as yreq] [yetti.response :as yres]) (:import + io.prometheus.client.CollectorRegistry io.undertow.server.RequestTooBigException)) (t/use-fixtures :once th/state-init) @@ -515,7 +519,8 @@ ;; handler in app.http/router-handler), this test asserts on ;; the ex-info thrown by wrap-parse-request directly. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] {::yres/status 200 ::yres/body :ok})) + (fn [_] {::yres/status 200 ::yres/body :ok}) + (th/metrics-stub)) ;; Body contains the bytes for: {"x": "\}"} -- a string ;; value with a backslash followed by '}', which ;; clojure.data.json v0.5.x cannot handle. @@ -532,13 +537,44 @@ (t/is (= :malformed-json (-> ex ex-data :code))) (t/is (= "invalid JSON in request body" (-> ex ex-data :hint))))) +(defn- malformed-requests-count + "Value of the malformed-body counter of a metrics instance." + [metrics] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + "penpot_http_malformed_requests_total" + (make-array String 0) + (make-array String 0))) + +(t/deftest parse-request-malformed-json-body + ;; The body shape seen in production: an object where the parser expects a + ;; key. clojure.data.json signals it with a bare java.lang.Exception, which + ;; the class-based classification did not recognize, so it surfaced as a 500 + ;; :unexpected with the whole stack trace in the log. It is a client error: + ;; 400 :malformed-json, and it is counted. + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics}) + handler (#'app.http.middleware/wrap-parse-request + (fn [_] {::yres/status 200 ::yres/body :ok}) + metrics) + request (th/make-dummy-request + {:method :post + :headers {"content-type" "application/json"} + :body-bytes (.getBytes "{\"events\":[{{\"a\":1}:\"b\"}]}" "UTF-8")}) + ex (try + (handler request) + (catch clojure.lang.ExceptionInfo e e))] + (t/is (instance? clojure.lang.ExceptionInfo ex)) + (t/is (= :validation (-> ex ex-data :type))) + (t/is (= :malformed-json (-> ex ex-data :code))) + (t/is (= 1.0 (malformed-requests-count metrics))))) + (t/deftest parse-request-request-too-big-exception ;; When RequestTooBigException is raised (e.g. the request body ;; exceeded the configured size limit), the middleware should ;; convert it to a 413 :request-body-too-large validation ;; error. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (RequestTooBigException. "too large")))) + (fn [_] (throw (RequestTooBigException. "too large"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -556,7 +592,8 @@ ;; was closed before the parser could read it), the middleware ;; should convert it to a 400 :malformed-json validation error. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (java.io.EOFException. "stream closed")))) + (fn [_] (throw (java.io.EOFException. "stream closed"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -579,7 +616,8 @@ wrapped (doto (RuntimeException. "wrapped") (.initCause iae)) handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw wrapped))) + (fn [_] (throw wrapped)) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -597,7 +635,8 @@ ;; path and return a 500 with :type :server-error :code ;; :unexpected. This is the "true internal error" path. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (RuntimeException. "boom")))) + (fn [_] (throw (RuntimeException. "boom"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -617,7 +656,8 @@ ;; has a dedicated handle-exception method that returns 500 ;; with :code :io-exception. (let [handler (#'app.http.middleware/wrap-parse-request - (fn [_] (throw (java.io.IOException. "network gone")))) + (fn [_] (throw (java.io.IOException. "network gone"))) + (th/metrics-stub)) request (th/make-dummy-request {:method :post :headers {"content-type" "application/json"} @@ -684,3 +724,23 @@ (t/is (= 409 (::yres/status response))) (t/is (= :conflict (:type body))) (t/is (= :file-modified (:code body))))) + +(t/deftest validation-error-body-with-raw-explain-is-json-serializable + ;; A :validation error whose ex-data carries the raw malli explain + ;; (storage invalid-storage-metadata) must produce a response body + ;; that survives JSON encoding. Before the fix the live schema + ;; object inside the explain broke the writer mid-stream and the + ;; client received a truncated body. + (let [cause (try + (stsch/encode-metadata {}) + (catch clojure.lang.ExceptionInfo cause cause)) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 400 (::yres/status response))) + (t/is (= :validation (:type body))) + (t/is (= :invalid-storage-metadata (:code body))) + (t/is (nil? (:app.common.schema/explain body))) + (let [encoded (json/encode body :key-fn json/write-camel-key) + decoded (json/decode encoded :key-fn json/read-kebab-key)] + (t/is (str/includes? encoded "invalid-storage-metadata")) + (t/is (string? (:explain decoded)))))) 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/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj deleted file mode 100644 index 863885a085..0000000000 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ /dev/null @@ -1,350 +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 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.jobs :as jobs] - [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] - (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 - ;; 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) "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" "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)))))))) - -(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}) - ;; 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*) - :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)})] - ((::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))] - (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)))))) - -(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/loggers_audit_consumer_task_test.clj b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj new file mode 100644 index 0000000000..17a5a2bbc3 --- /dev/null +++ b/backend/test/backend_tests/loggers_audit_consumer_task_test.clj @@ -0,0 +1,831 @@ +;; 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-consumer-task-test + (:require + [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.consumer-task :as consumer] + [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]]) + (: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 consumers, like append-audit-entry does." + [row-id] + (consumer/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] + (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- admin-console-event + [admin-console-mock] + (let [[_ _ params] (:call-args @admin-console-mock)] + (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 + order). The admin-console job takes no params (closed empty schema)." + [params] + (th/run-task! :audit-log-consumer-nexus params) + (th/run-task! :audit-log-consumer-admin-console {})) + +(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 + ;; 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 [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-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-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)) + (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"])] + (t/is (= 0 (count rows)))))))) + +(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} + 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" "auditlog-exported"} + (into #{} (map :name) (:events params))))) + (let [rows (th/db-exec! ["select * from audit_log"])] + (t/is (= 0 (count rows)))))))) + +(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} + http-mock {:target 'app.http.client/req :return {:status 204}}] + (insert-audit-row! (:id prof) "unrelated-event") + (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"])] + (t/is (= 0 (count rows)))))))) + +(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-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 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-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 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. + (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 + :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-consumer-nexus {:uri archive-uri}) + (t/is (thrown? clojure.lang.ExceptionInfo + (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)) + (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 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-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 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-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 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-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 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 + :admin-console-uri "http://ac.example/admin-console/" + :admin-console-shared-key "ac-key" + :nexus-shared-key "nexus-key")] + (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}) + ;; 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.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.consumer-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! admin-console-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)})] + (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)))) + (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"])] + (t/is (= 0 (count rows)))))))))) + +(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 :nexus-uri)] + (let [data (ex-data (try + (th/run-task! :audit-log-consumer-nexus {}) + (catch Throwable cause + cause)))] + (t/is (= :task-not-configured (:code data)))))) + +(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)})] + (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) + :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-consumer-admin-console {}) + (t/is (:called? @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))) + ;; 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)))))))) + +(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)})] + (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 [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-consumer-admin-console {}) + (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 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)})] + (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) + :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-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))) + (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 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}] + (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-consumer-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"])] + (t/is (= 0 (count rows)))))))) + +(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}] + (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) + :files 1 + :export-type "detach-libraries"}) + (insert-audit-row! (:id actor) "export-binfile" + {:file-id (uuid/next)}) + (th/run-task! :audit-log-consumer-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"])] + (t/is (= 0 (count rows)))))))) + +(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 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)}))) + (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 consumer-metric-ids + [:audit-consumer-events :audit-consumer-discarded]) + +(defn- make-consumer-metrics + [] + (ig/init-key :app.metrics/metrics + {:default (select-keys main/default-metrics consumer-metric-ids)})) + +(defn- invoke-nexus! + "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.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*) + ::mtx/metrics metrics})] + ((::jobs/handler job-def) nil params))) + +(defn- invoke-admin-console! + "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.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 consumer 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 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-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-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)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; 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 consumer-submit-creates-pendings-for-enabled-consumers + ;; The producer hook: a row is born pending for the enabled + ;; 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})] + (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 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*)} + (full-event (:id prof))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count)))))) + +(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})] + (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 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-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} + 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 (= :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-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} + 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 consumer-sweeps-orphan-rows + ;; A row with no pendings (old archived leftover, or born while no + ;; 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} + 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 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-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-consumer-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count))))))) + +(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. + (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-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 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-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, consumer) + 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-consumer-discarded ["reaped"]))) + (t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 0 (pending-count)))))))) + +(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-consumer-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-consumer-discarded ["swept"]))) + (t/is (= 0.0 (counter-value metrics :audit-consumer-events ["nexus" "archived"]))))))) + +(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-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 + :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-consumer-events ["nexus" "failed"]))) + (t/is (= 1 (count (th/db-exec! ["select * from audit_log"])))) + (t/is (= 1 (pending-count))))))) + +(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. + (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-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 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-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-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/loggers_metrics_test.clj b/backend/test/backend_tests/loggers_metrics_test.clj new file mode 100644 index 0000000000..984ad3b0f8 --- /dev/null +++ b/backend/test/backend_tests/loggers_metrics_test.clj @@ -0,0 +1,135 @@ +;; 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 backend-tests.loggers-metrics-test + "The reporter that turns log records into a counter." + (:require + [app.common.logging :as l] + [app.loggers.metrics] ;; registers the integrant methods of the reporter key + [app.main :as main] + [app.metrics :as mtx] + [clojure.test :as t] + [integrant.core :as ig]) + (:import + io.prometheus.client.CollectorRegistry)) + +(def ^:private logger + "The logger of a record is the namespace of the code that logs, and that is + this namespace." + (str *ns*)) + +(defn- init-metrics + [] + (ig/init-key :app.metrics/metrics {:default main/default-metrics})) + +(defn- init-reporter + [metrics] + (ig/init-key :app.loggers.metrics/reporter {::mtx/metrics metrics})) + +(defn- halt-reporter! + [reporter] + (ig/halt-key! :app.loggers.metrics/reporter reporter)) + +(defn- sample + "Value of the counter for one logger and level, read from the registry. Nil + when that combination has never been published." + [metrics logger level] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + "penpot_log_events_total" + (into-array String ["logger" "level"]) + (into-array String [logger level]))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; COUNTING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest warn-and-error-are-counted-by-logger-and-level + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + ;; `:sync?` so the record is emitted on this thread and the assertion + ;; does not depend on the logging executor. + (l/warn :hint "una prueba" ::l/sync? true) + (l/error :hint "otra prueba" ::l/sync? true) + + (t/is (= 1.0 (sample metrics logger "warn"))) + (t/is (= 1.0 (sample metrics logger "error"))) + + (l/error :hint "y otra mas" ::l/sync? true) + (t/is (= 2.0 (sample metrics logger "error"))) + + (finally + (halt-reporter! reporter))))) + +(t/deftest info-is-not-counted + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + (l/info :hint "un mensaje normal" ::l/sync? true) + (t/is (nil? (sample metrics logger "info"))) + + (finally + (halt-reporter! reporter))))) + +(t/deftest fatal-is-counted + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + ;; No macro of the logging emits `:fatal`; this is the only way a record + ;; gets that level. + (l/log :fatal :hint "algo muy malo" ::l/sync? true) + (t/is (= 1.0 (sample metrics logger "fatal"))) + + (finally + (halt-reporter! reporter))))) + +(t/deftest halting-the-reporter-stops-counting + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + + (halt-reporter! reporter) + + (l/error :hint "despues de parar" ::l/sync? true) + (t/is (nil? (sample metrics logger "error"))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SAFETY +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest a-failing-publication-does-not-break-the-logging + ;; The watcher fires from the `swap!` of the log record, inside the logging + ;; path. With `::l/sync?` an exception here would reach whoever was logging; + ;; on the async path it would end in the executor's uncaught handler, which + ;; is outside the application's logging, taking that log line out of Loki and + ;; out of this counter. + (let [metrics (init-metrics) + reporter (init-reporter metrics)] + (try + (with-redefs [mtx/run! (fn [& _] (throw (ex-info "publicando mal" {})))] + (t/is (nil? (l/error :hint "no debe propagar" ::l/sync? true)))) + + (finally + (halt-reporter! reporter))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; DEFINITION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-log-events-metric-is-defined + (let [metrics (init-metrics)] + (t/is (= ["logger" "level"] + (:app.metrics.definition/labels (mtx/get-collector metrics :log-events)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; WIRING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest the-system-declares-the-reporter + ;; The reporter hooks itself to the logging, so declaring it in the system is + ;; all the wiring there is. The tests of the suite remove it, so this is the + ;; only place that pins it. + (t/is (= {::mtx/metrics (ig/ref ::mtx/metrics)} + (:app.loggers.metrics/reporter main/system-config)))) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index fd3a1bd5bf..e2c540119c 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -9,6 +9,7 @@ [app.common.time :as ct] [app.common.transit :as transit] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.http :as http] [app.jobs :as jobs] @@ -20,6 +21,12 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(defn decode-row + [{:keys [props context] :as row}] + (cond-> row + (db/pgobject? props) (assoc :props (db/decode-transit-pgobject props)) + (db/pgobject? context) (assoc :context (db/decode-transit-pgobject context)))) + (t/deftest process-event-handler-with-no-webhooks (with-mocks [submit-mock {:target 'app.jobs/submit :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) diff --git a/backend/test/backend_tests/metrics_test.clj b/backend/test/backend_tests/metrics_test.clj index 620d49b8c0..916c74aabd 100644 --- a/backend/test/backend_tests/metrics_test.clj +++ b/backend/test/backend_tests/metrics_test.clj @@ -7,6 +7,7 @@ (ns backend-tests.metrics-test (:require [app.common.exceptions :as ex] + [app.main :as main] [app.metrics :as mtx] [app.metrics.definition :as-alias mdef] [clojure.test :as t] @@ -163,3 +164,72 @@ (finally (px/shutdown-now sampler))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HISTOGRAM BUCKETS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest histogram-bucket-tails-extend-past-7-5s + ;; The sub-7.5s prefix stays identical, so p50/p90 resolution and the + ;; history remain comparable; only the tail grows, per latency profile. + (t/is (= mtx/default-histogram-buckets + (subvec mtx/default-request-histogram-buckets 0 14))) + (t/is (= mtx/default-histogram-buckets + (subvec mtx/default-tasks-histogram-buckets 0 14))) + (t/is (= mtx/default-histogram-buckets + (subvec mtx/default-redis-histogram-buckets 0 14))) + + (t/is (= [10000 15000 30000 60000] + (subvec mtx/default-request-histogram-buckets 14))) + (t/is (= [10000 15000 30000 60000 120000 300000] + (subvec mtx/default-tasks-histogram-buckets 14))) + (t/is (= [10000] + (subvec mtx/default-redis-histogram-buckets 14)))) + +(t/deftest default-metrics-wire-bucket-tails-per-profile + ;; Requests and waits cover the 30s/60s CLIMIT timeouts, tasks run + ;; until done (300s postgres statement timeout), Redis only needs the + ;; pathological case covered. + (doseq [id [:rpc-main-timing :rpc-management-timing :rpc-climit-timing + :audit-http-handler-timing :http-server-dispatch-timing + :update-file-validation-timing]] + (t/is (= mtx/default-request-histogram-buckets + (get-in main/default-metrics [id ::mdef/buckets])) + (str "request buckets on " id))) + (t/is (= mtx/default-tasks-histogram-buckets + (get-in main/default-metrics [:tasks-timing ::mdef/buckets]))) + (t/is (= mtx/default-redis-histogram-buckets + (get-in main/default-metrics [:redis-eval-timing ::mdef/buckets])))) + +(defn- bucket-value + "Cumulative count of the `le` bucket of a histogram, read from the + registry. Nil when that boundary does not exist." + [metrics mname label-names label-values le] + (.getSampleValue ^CollectorRegistry (mtx/get-registry metrics) + (str mname "_bucket") + (into-array String (conj (vec label-names) "le")) + (into-array String (conj (vec label-values) le)))) + +(t/deftest histogram-tails-are-registered + ;; A 9s observation must land in the new tail instead of piling up in + ;; +Inf: every histogram used to stop at 7500. + (let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics})] + (mtx/run! metrics :id :rpc-main-timing :val 9000 :labels (into-array String ["update-file"])) + (t/is (= 0.0 (bucket-value metrics "penpot_rpc_main_timing" ["name"] ["update-file"] "7500.0"))) + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_main_timing" ["name"] ["update-file"] "10000.0"))) + (t/is (nil? (bucket-value metrics "penpot_rpc_main_timing" ["name"] ["update-file"] "120000.0"))) + + (mtx/run! metrics :id :tasks-timing :val 200000 :labels (into-array String ["file-gc"])) + (t/is (= 0.0 (bucket-value metrics "penpot_tasks_timing" ["name"] ["file-gc"] "60000.0"))) + (t/is (= 1.0 (bucket-value metrics "penpot_tasks_timing" ["name"] ["file-gc"] "300000.0"))) + + (mtx/run! metrics :id :redis-eval-timing :val 9000 :labels (into-array String ["claim"])) + (t/is (= 1.0 (bucket-value metrics "penpot_redis_eval_timing" ["name"] ["claim"] "10000.0"))) + (t/is (nil? (bucket-value metrics "penpot_redis_eval_timing" ["name"] ["claim"] "15000.0"))) + + (mtx/run! metrics :id :http-server-dispatch-timing :val 45000) + (t/is (= 1.0 (bucket-value metrics "penpot_http_server_dispatch_timing" [] [] "60000.0"))) + + (mtx/run! metrics :id :update-file-validation-timing :val 9000 :labels (into-array String ["validate-affected"])) + (t/is (= 0.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" ["step"] ["validate-affected"] "7500.0"))) + (t/is (= 1.0 (bucket-value metrics "penpot_rpc_update_file_validation_timing" ["step"] ["validate-affected"] "10000.0"))))) 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'))) diff --git a/backend/test/backend_tests/objects_gc_test.clj b/backend/test/backend_tests/objects_gc_test.clj index 99a1314d13..6696a7f2f0 100644 --- a/backend/test/backend_tests/objects_gc_test.clj +++ b/backend/test/backend_tests/objects_gc_test.clj @@ -8,11 +8,14 @@ (:require [app.common.logging :as l] [app.common.time :as ct] + [app.config :as cf] [app.db :as db] + [app.rpc :as-alias rpc] [app.storage :as sto] [app.tasks.objects-gc :as ogc] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -123,3 +126,119 @@ (t/testing "the run finishes" (t/is (pos? (:processed result)))))) + +(defn- submitted-events + [audit-mock] + (->> (:call-args-list @audit-mock) + (map second))) + +(defn- events-named + [audit-mock event-name] + (filter #(= event-name (:name %)) + (submitted-events audit-mock))) + +(t/deftest gc-emits-hard-delete-objects-for-teams + ;; When the GC physically deletes a team, the projection must drop its row, + ;; so the event carries the purged ids. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 71 {:profile-id (:id profile)})] + (th/command! {::th/type :delete-team + ::rpc/profile-id (:id profile) + :id (:id team)}) + ;; Run the queued cascade so the default project is marked deleted too, + ;; then purge everything the way production does. + (th/run-pending-jobs) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :team {:id (:id team)} {::db/remove-deleted false}))) + (let [events (filter #(contains? (:props %) :team-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "command" (:type event))) + (t/is (= [(:id team)] (get-in event [:props :team-ids]))) + (t/is (= "objects-gc" (get-in event [:context :triggered-by]))))))))) + +(t/deftest gc-emits-hard-delete-objects-for-projects + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 72 {:profile-id (:id profile)}) + project (th/create-project* 72 {:profile-id (:id profile) + :team-id (:id team)})] + (th/command! {::th/type :delete-project + ::rpc/profile-id (:id profile) + :id (:id project)}) + (th/run-pending-jobs) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :project {:id (:id project)} {::db/remove-deleted false}))) + (let [events (filter #(contains? (:props %) :project-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "command" (:type event))) + (t/is (= [(:id project)] (get-in event [:props :project-ids]))) + (t/is (= "objects-gc" (get-in event [:context :triggered-by]))))))))) + +(t/deftest gc-emits-hard-delete-objects-for-files + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 73 {:profile-id (:id profile)}) + project (th/create-project* 73 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 73 {:profile-id (:id profile) + :project-id (:id project)})] + (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id file)}) + (th/run-pending-jobs) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :file {:id (:id file)} {::db/remove-deleted false}))) + (let [events (filter #(contains? (:props %) :file-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "command" (:type event))) + (t/is (= [(:id file)] (get-in event [:props :file-ids]))) + (t/is (= "objects-gc" (get-in event [:context :triggered-by]))))))))) + +(t/deftest gc-emits-no-event-without-audit-log-flag + ;; The GC must stay silent when the audit log is off, even though the row is + ;; still purged. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 74 {:profile-id (:id profile)})] + (th/command! {::th/type :delete-team + ::rpc/profile-id (:id profile) + :id (:id team)}) + (th/run-pending-jobs) + (th/reset-mock! audit-mock) + (th/run-task! :objects-gc {:skip-delay true}) + (t/is (nil? (th/db-get :team {:id (:id team)} {::db/remove-deleted false}))) + (t/is (empty? (:call-args-list @audit-mock)))))) + +(t/deftest gc-emits-events-only-for-committed-chunks + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1) + rolled-back (create-deleted-file! profile 1 (ct/in-past "30m")) + blocked (create-deleted-file! profile 2 (ct/in-past "20m")) + clean (create-deleted-file! profile 3 (ct/in-past "10m"))] + (block-file! blocked) + (th/reset-mock! audit-mock) + (let [result (th/run-task! :objects-gc {:chunk-size 2}) + events (filter #(contains? (:props %) :file-ids) + (events-named audit-mock "hard-delete-objects"))] + (t/testing "the failed chunk keeps both files" + (t/is (some? (find-file (:id rolled-back)))) + (t/is (some? (find-file (:id blocked))))) + (t/testing "the next chunk commits and reports only its deleted file" + (t/is (nil? (find-file (:id clean)))) + (t/is (pos? (:processed result))) + (t/is (= 1 (count events))) + (t/is (= [(:id clean)] (get-in (first events) [:props :file-ids]))))))))) 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/backend/test/backend_tests/redis_test.clj b/backend/test/backend_tests/redis_test.clj new file mode 100644 index 0000000000..df3c8e15a8 --- /dev/null +++ b/backend/test/backend_tests/redis_test.clj @@ -0,0 +1,76 @@ +;; 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 backend-tests.redis-test + "The redis wrapper swallows a command timeout and returns nil, so the log + line is the only trace of the command that did not reach the server. These + tests pin what that line has to carry." + (:require + [app.common.logging :as l] + [app.redis :as rds] + [clojure.test :as t]) + (:import + io.lettuce.core.RedisCommandTimeoutException)) + +(def ^:private timeout-hint "timeout on del redis key") + +(defn- timeout-connection + "A connection whose DEL always exceeds the command timeout, the way Lettuce + answers when the response does not arrive in time." + [] + (reify rds/IDefaultConnection + (-del [_ _keys] + (throw (RedisCommandTimeoutException. "DEL. Command timed out after 2 second(s)"))))) + +(defn- capture-records + "Run `f` with every log record collected, newest last. The logging API emits + asynchronously, so a record may land after `f` returns; callers wait for the + one they need." + [f] + (let [records (atom [])] + (add-watch l/log-record ::capture + (fn [_ _ _ record] + (swap! records conj record))) + (try + (f records) + (finally + (remove-watch l/log-record ::capture))))) + +(defn- timeout-record + "Wait until the record that reports a timed out DEL is captured." + [records timeout] + (let [deadline (+ (System/currentTimeMillis) timeout)] + (loop [] + (or (some (fn [record] + (when (= timeout-hint (get (::l/props record) :hint)) + record)) + @records) + (when (<= (System/currentTimeMillis) deadline) + (Thread/sleep 10) + (recur)))))) + +(t/deftest del-logs-the-keys-that-timed-out + ;; The server never saw the command, so the operator has no other way to know + ;; which key stayed behind. + (capture-records + (fn [records] + (t/is (nil? (rds/del (timeout-connection) ["file-summary:1" "file-summary:2"]))) + (let [record (timeout-record records 2000)] + (t/is (some? record) "a timed out DEL must leave a log line") + (t/is (= ["file-summary:1" "file-summary:2"] + (get (::l/props record) :keys))) + (t/is (instance? RedisCommandTimeoutException (::l/cause record))) + (t/is (= :error (::l/level record))) + (t/is (= "app.redis" (::l/logger record))))))) + +(t/deftest del-logs-a-single-key-as-a-collection + ;; `del` accepts one key or a collection and always hands a collection to the + ;; driver; the log keeps the same shape so it does not depend on the call. + (capture-records + (fn [records] + (rds/del (timeout-connection) "file-summary:1") + (t/is (= ["file-summary:1"] + (get (::l/props (timeout-record records 2000)) :keys)))))) 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)) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 50ee79f2e8..8ae01500ad 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -8,16 +8,30 @@ (:require [app.common.pprint :as pp] [app.common.time :as ct] + [app.common.transit :as transit] [app.common.uuid :as uuid] [app.config :as cf] [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] - [yetti.request])) + [integrant.core :as ig] + [mockery.core :refer [with-mocks]] + [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) @@ -94,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) @@ -124,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 consumer. + (t/is (= 1 (count (th/db-exec! ["select * from audit_log_pending"])))))))) (t/deftest push-events-2 (with-redefs [app.config/flags #{:audit-log}] @@ -225,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 consumers. + (t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"]))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; BACKEND PROCESS-EVENT PATH (RPC commands) @@ -329,6 +350,60 @@ (t/is (= day-now (:created-at row2))) (t/is (= day-now (:tracked-at row2)))))))) +(t/deftest backend-process-event-forwards-id-to-webhook-task + ;; The `webhook/trigger` row links back to its origin event through + ;; props `:event-id`, so `process-event` must forward the stored row + ;; id to the `:process-webhook-event` worker task. Regression test: + ;; the id used to be generated inside `append-audit-entry` without + ;; ever reaching the worker params, so `:event-id` always landed nil. + (with-mocks [submit-mock {:target 'app.jobs/submit :return nil}] + (with-redefs [cf/flags #{:audit-log :webhooks}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:type "action" + :name "delete-project" + :profile-id (:id prof) + :props {:team-id (:default-team-id prof)} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend" + :app.loggers.webhooks/event? true}] + (audit/submit* th/*system* event) + (let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= 1 (count rows))) + (t/is (= 1 (:call-count @submit-mock))) + (let [params (-> @submit-mock :call-args-list first second ::jobs/params) + task-event (transit/decode-str (:event-blob params))] + (t/is (some? (:id task-event))) + (t/is (= (:id row) (:id task-event))))))))) + +(t/deftest backend-process-event-telemetry-row-gets-own-id + ;; When both `:audit-log` and `:telemetry` are active, the shadow + ;; telemetry row must not reuse the full row id (shared PK), while + ;; the webhook task still carries the full row id. + (with-mocks [submit-mock {:target 'app.jobs/submit :return nil}] + (with-redefs [cf/flags #{:audit-log :telemetry :webhooks}] + (let [prof (th/create-profile* 1 {:is-active true}) + event {:type "action" + :name "delete-project" + :profile-id (:id prof) + :props {:team-id (:default-team-id prof)} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend" + :app.loggers.webhooks/event? true}] + (audit/submit* th/*system* event) + (let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"]) + (mapv decode-row))] + (t/is (= 2 (count rows))) + (t/is (not= (:id row1) (:id row2))) + (let [params (-> @submit-mock :call-args-list first second ::jobs/params) + task-event (transit/decode-str (:event-blob params))] + (t/is (= (:id (first (filter #(= "backend" (:source %)) rows))) + (:id task-event))))))))) + (t/deftest push-events-disabled-when-no-flags-and-no-telemetry ;; When neither audit-log nor telemetry is enabled, no rows should ;; be stored. @@ -651,17 +726,22 @@ (defn- prepare-event "Call prepare-rpc-event with a bare request, returning the built event." - [caller result] - (let [mdata {::sv/name "test-cmd"} - params {::rpc/profile-id caller - ::rpc/request-id (uuid/next) - ::rpc/request-at (ct/now)} - mock-req (reify - yetti.request/IRequest - (get-header [_ _] nil) - (remote-addr [_] "127.0.0.1")) - params (with-meta params {:app.http/request mock-req})] - (audit/prepare-rpc-event th/*system* mdata params result))) + ([caller result] + (prepare-event caller "test-cmd" result)) + ([caller cmd-name result] + (let [mdata {::sv/name cmd-name} + params {::rpc/profile-id caller + ::rpc/request-id (uuid/next) + ::rpc/request-at (ct/now)} + mock-req (reify + yetti.request/IRequest + (get-header [_ _] nil) + (remote-addr [_] "127.0.0.1")) + params (with-meta params {:app.http/request mock-req})] + ;; Production always resolves commands with `::module` set ("main" or + ;; "management"): the event name (and the read exclusion) depends on it. + (audit/prepare-rpc-event (assoc th/*system* ::rpc/module "main") + mdata params result)))) (t/deftest prepare-rpc-event-ignores-profile-id-from-result ;; An audit event belongs to the caller, never to whatever `:profile-id` @@ -696,6 +776,76 @@ (t/is (= uuid/zero (:profile-id (prepare-event nil {:some-data "value"}))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PREPARE-RPC-EVENT TEAM-ID ENRICHMENT +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest prepare-rpc-event-enriches-team-id-from-project-id + ;; An event that carries a project id resolves its team locally, so the + ;; downstream consumers do not need to join production tables. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 10 {:profile-id (:id profile)}) + project (th/create-project* 10 {:profile-id (:id profile) + :team-id (:id team)}) + result (with-meta {} {::audit/props {:project-id (:id project)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:id team) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-enriches-team-id-from-file-id + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 11 {:profile-id (:id profile)}) + project (th/create-project* 11 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 11 {:profile-id (:id profile) + :project-id (:id project)}) + result (with-meta {} {::audit/props {:file-id (:id file)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:id team) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-keeps-existing-team-id + ;; The resolver never overwrites a team id the command already set. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 12 {:profile-id (:id profile)}) + project (th/create-project* 12 {:profile-id (:id profile) + :team-id (:id team)}) + result (with-meta {} {::audit/props {:team-id (:id team) + :project-id (:id project)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:id team) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-does-not-resolve-without-project-or-file + ;; Read commands put the entity id under `:id`; enriching those would add a + ;; query to every read event, so the resolver must leave them untouched. + (let [profile (th/create-profile* 1 {:is-active true}) + result (with-meta {} {::audit/props {:id (uuid/next)}}) + event (prepare-event (:id profile) result)] + (t/is (not (contains? (:props event) :team-id))))) + +(t/deftest prepare-rpc-event-resolves-default-team-id + ;; The resolver adds the team id whenever it can resolve it, including the + ;; personal (default) team. + (let [profile (th/create-profile* 1 {:is-active true}) + result (with-meta {} {::audit/props {:project-id (:default-project-id profile)}}) + event (prepare-event (:id profile) result)] + (t/is (= (:default-team-id profile) (get-in event [:props :team-id]))))) + +(t/deftest prepare-rpc-event-skips-team-id-resolution-for-reads + ;; Read events carry no consumer state: resolving their team would add a + ;; query to every read, so `get-*` names (plus the known non-get reads) + ;; are left untouched even when they carry a project or file id. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 13 {:profile-id (:id profile)}) + project (th/create-project* 13 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 13 {:profile-id (:id profile) + :project-id (:id project)})] + (doseq [cmd-name ["get-file-fragment" "get-comments" "has-file-libraries"] + props [{:file-id (:id file)} {:project-id (:id project)}]] + (let [result (with-meta {} {::audit/props props}) + event (prepare-event (:id profile) cmd-name result)] + (t/is (not (contains? (:props event) :team-id)) + (str cmd-name " with " (keys props) " must not resolve team-id")))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PREPARE-RPC-EVENT PROFILE-ID COERCION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -725,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/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index b1a144ab70..0447fe135d 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -6,13 +6,17 @@ (ns backend-tests.rpc-binfile-test (:require + [app.binfile.v3 :as bf.v3] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [backend-tests.helpers :as th] [clojure.test :as t] - [datoteka.fs :as fs])) + [datoteka.fs :as fs] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -41,6 +45,50 @@ (t/is (false? (validator params-with-file-id)) "params with file-id should be rejected"))) +(t/deftest import-binfile-schema-accepts-path-object + ;; Documents the schema hole: a server-side Path object in :file passes + ;; validation on shape alone. The multipart guard in the handler (not the + ;; schema) is what rejects client-forged paths. + (let [schema @#'binfile/schema:import-binfile + validator (sm/lazy-validator schema)] + (t/is (true? (validator {:name "pwned" + :project-id (uuid/random) + :version 3 + :file {:filename "evil.zip" + :size 123 + :path (fs/path "/etc/passwd")}}))))) + +(t/deftest import-binfile-rejects-forged-file-without-multipart + ;; Path-traversal PoC at command level: a transit body like + ;; {"~:file": {"~:path": ["~#path", "/etc/passwd"]}} decodes to this exact + ;; map. Without a multipart request it must fail at params validation, + ;; before any file is read. + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :import-binfile + ::rpc/profile-id (:id prof) + :name "pwned" + :version 3 + :project-id (:default-project-id prof) + :file {:filename "evil.zip" + :size 123 + :path (fs/path "/etc/passwd")}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest import-binfile-rejects-forged-file-with-forged-flag + ;; The multipart flag is server-side: smuggling it in the body must not + ;; bypass the guard (prepare-rpc-params overwrites it from the request). + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :import-binfile + ::rpc/profile-id (:id prof) + :app.rpc/is-multipart true + :name "pwned" + :version 3 + :project-id (:default-project-id prof) + :file {:filename "evil.zip" + :size 123 + :path (fs/path "/etc/passwd")}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + (t/deftest import-binfile-schema-rejects-unsupported-version ;; T1-N2-03: version parameter should be restricted to supported values (1 or 3) (let [schema @#'binfile/schema:import-binfile @@ -72,3 +120,38 @@ ;; Version 4 should be rejected (t/is (false? (validator (assoc base-params :version 4))) "version 4 should be rejected"))) + +(t/deftest import-binfile-audit-events-carry-entity-ids + ;; The imported files are created inside the importer, so the command emits a + ;; create-file event per file for a local projection. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 51 {:profile-id (:id profile)}) + project (th/create-project* 51 {:profile-id (:id profile) + :team-id (:id team)}) + ;; A real row: `submit-file-created-event` reads the light + ;; columns back, so a phantom id would emit no event. + file (th/create-file* 99 {:profile-id (:id profile) + :project-id (:id project)}) + file-id (:id file)] + (with-redefs [bf.v3/import-files! (fn [_cfg] {:file-ids [file-id]})] + (#'binfile/import-binfile + th/*system* + {::rpc/profile-id (:id profile) + :profile-id (:id profile) + :project-id (:id project) + :version 3 + :name "imported" + :file {:filename "package.penpot" + :path (th/tempfile "backend_tests/test_files/svg-attrs-camel-case.penpot") + :mtype "application/zip" + :size 1}})) + + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "create-file" (:name %))))] + (t/is (= 1 (count events))) + (t/is (= file-id (get-in (first events) [:props :id]))) + (t/is (= (:id project) (get-in (first events) [:props :project-id]))) + (t/is (= (:id team) (get-in (first events) [:props :team-id])))))))) diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj index ea64723545..0c58687751 100644 --- a/backend/test/backend_tests/rpc_comment_test.clj +++ b/backend/test/backend_tests/rpc_comment_test.clj @@ -478,3 +478,32 @@ :content "member comment" :frame-id uuid/zero})] (t/is (th/success? out)))))) + +(t/deftest comment-audit-events-carry-team-id + ;; Comment events carry the file id, so the resolver could find the team; the + ;; command already has the file with its team id and sets it without a join. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-teams-per-profile 200})}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id profile)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0]) + thread (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id profile) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "hello" + :frame-id uuid/zero}) + comment (th/command! {::th/type :create-comment + ::rpc/profile-id (:id profile) + :thread-id (get-in thread [:result :id]) + :content "comment"})] + (t/is (= (:id team) + (get-in (meta (:result thread)) [:app.loggers.audit/props :team-id]))) + (t/is (= (:id team) + (get-in (meta (:result comment)) [:app.loggers.audit/props :team-id])))))) diff --git a/backend/test/backend_tests/rpc_file_security_test.clj b/backend/test/backend_tests/rpc_file_security_test.clj new file mode 100644 index 0000000000..15ac56c53d --- /dev/null +++ b/backend/test/backend_tests/rpc_file_security_test.clj @@ -0,0 +1,368 @@ +;; 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 backend-tests.rpc-file-security-test + "Tests for the nexus file-security backend prerequisite: the + `get-file-for-analysis` management method, the semantic projection, + and the audit prop coverage the `:local` consumer schedules from." + (:require + [app.binfile.common :as bfc] + [app.common.features :as cfeat] + [app.common.time :as ct] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.loggers.audit :as audit] + [app.rpc :as-alias rpc] + [app.rpc.management.files :as mgmt.files] + [app.util.services :as sv] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.request])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; --- Projection unit tests (pure, no database) + +(def ^:private page-id (uuid/random)) +(def ^:private frame-id (uuid/random)) +(def ^:private text-id (uuid/random)) +(def ^:private link-id (uuid/random)) + +(defn- test-data + [objects] + {:pages [page-id] + :pages-index {page-id {:id page-id + :name "Page 1" + :objects objects}}}) + +(t/deftest projection-empty-file + (let [projection (mgmt.files/file->projection (test-data {}))] + (t/is (= 1 (:version projection))) + (t/is (= [{:id (str page-id) :name "Page 1"}] (:pages projection))) + (t/is (= [] (:frames projection))) + (t/is (= {:items [] :truncated? false} (:texts projection))) + (t/is (= {:items [] :truncated? false} (:links projection))) + (t/is (= {:items [] :truncated? false} (:interactions projection))))) + +(t/deftest projection-picks-frames-text-links-and-interactions + (let [data (test-data + {frame-id {:id frame-id :name "Frame" :type :frame} + text-id {:id text-id :name "Text" :type :text + :content {:type "root" + :children [{:type "paragraph" + :children [{:text "hello"} + {:text " world"}]}]}} + link-id {:id link-id :name "Button" :type :rect + :interactions [{:event-type :click + :action-type :open-url + :url "https://phish.example/login"} + {:event-type :click + :action-type :navigate + :destination page-id} + ;; No event, cannot trigger: + ;; collected as link, never + ;; as interaction. + {:action-type :open-url + :url "https://quiet.example"}]}}) + projection (mgmt.files/file->projection data)] + (t/is (= [{:id (str frame-id) :page-id (str page-id) :name "Frame"}] + (:frames projection))) + (t/is (= {:items [{:shape-id (str text-id) + :page-id (str page-id) + :text "hello world"}] + :truncated? false} + (:texts projection))) + (t/is (= {:items [{:shape-id (str link-id) + :page-id (str page-id) + :url "https://phish.example/login"} + {:shape-id (str link-id) + :page-id (str page-id) + :url "https://quiet.example"}] + :truncated? false} + (:links projection))) + (t/is (= {:items [{:shape-id (str link-id) + :page-id (str page-id) + :event "click" + :action "open-url" + :target "https://phish.example/login"} + {:shape-id (str link-id) + :page-id (str page-id) + :event "click" + :action "navigate" + :target (str page-id)}] + :truncated? false} + (:interactions projection))))) + +(t/deftest projection-skips-blank-text-and-corrupt-content + (let [bad-id (uuid/random) + projection (mgmt.files/file->projection + (test-data + {text-id {:id text-id :type :text + :content {:type "root" + :children [{:type "paragraph" + :children [{:text " "}]}]}} + bad-id {:id bad-id :type :text + :content {:type "root" :children 42}}}))] + ;; Blank text contributes nothing; corrupt content skips the shape + ;; instead of failing the file. + (t/is (= {:items [] :truncated? false} (:texts projection))))) + +(t/deftest projection-truncates-links + (let [objects (into {} + (map (fn [i] + (let [id (uuid/random)] + [id {:id id :type :rect + :interactions [{:event-type :click + :action-type :open-url + :url (str "https://example.com/" i)}]}]))) + (range (inc mgmt.files/max-links))) + projection (mgmt.files/file->projection (test-data objects))] + (t/is (= mgmt.files/max-links (count (get-in projection [:links :items])))) + (t/is (true? (get-in projection [:links :truncated?]))) + (t/is (false? (get-in projection [:interactions :truncated?]))))) + +;; --- Management method (through the worker stack) + +(def text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "cheap meds"}]}]}]}) + +(defn- create-file! + [profile-id project-id] + (let [out (th/command! {::th/type :create-file + ::rpc/profile-id profile-id + :project-id project-id + :name "Shop" + :is-shared false})] + (t/is (nil? (:error out))) + (get-in out [:result :id]))) + +(defn- file-page-id + [file-id] + (get-in (bfc/get-file th/*system* file-id) [:data :pages 0])) + +(defn- update-file! + [profile-id file-id page-id changes revn] + (let [out (th/command! {::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})] + (t/is (nil? (:error out))) + (:result out))) + +(defn- add-text-shape-change + [page-id shape-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 "Buy now" + :frame-id uuid/zero + :parent-id uuid/zero + :type :text + :content text-content})}]) + +(t/deftest get-file-for-analysis-returns-projection + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file-id (create-file! (:id profile) (:id project)) + page-id (file-page-id file-id)] + ;; One edit so revn moves past zero and the file has content. + (update-file! (:id profile) file-id page-id + (add-text-shape-change page-id (uuid/random)) + 0) + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id file-id})] + (t/is (nil? (:error out))) + (let [result (:result out)] + (t/is (= file-id (:id result))) + (t/is (= 1 (:revn result))) + (t/is (some? (:modified-at result))) + (t/is (= (:id project) (:project-id result))) + (t/is (= (:id team) (:team-id result))) + (t/is (false? (:has-share-link result))) + (t/is (= mgmt.files/projection-version (:projection-version result))) + (t/is (= "cheap meds" + (-> result :projection :texts :items first :text))))) + ;; A share link flips the share state, no re-edit needed. + (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id profile) + :file-id file-id + :pages #{page-id} + :who-comment "all" + :who-inspect "all"}) + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id file-id})] + (t/is (nil? (:error out))) + (t/is (true? (:has-share-link (:result out))))))) + +(t/deftest get-file-for-analysis-rejects-unknown-file + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id (uuid/random)})] + (t/is (some? (:error out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + +;; --- Audit prop coverage (through the commands, into real events) +;; +;; NOTE: the test system neutralizes `audit/submit`, so no audit row +;; is ever written. The observable contract of a command is the +;; metadata it sets on its result, which `prepare-rpc-event` turns +;; into the stored event: feed one into the other with the real +;; method metadata and assert the final props. + +(def mock-request + (reify + yetti.request/IRequest + (get-header [_ _] nil) + (remote-addr [_] "127.0.0.1"))) + +(defn- event-for + "Run a main RPC command and return the audit event it would emit." + [type params] + (let [[mdata _] (get-in th/*system* [:app.rpc/methods type]) + result (th/command-raw! (assoc params ::th/type type)) + params (-> params + (assoc ::rpc/request-id (uuid/next) + ::rpc/request-at (ct/now)) + (with-meta {:app.http/request mock-request})) + ;; In production the caller assocs the module before + ;; preparing the event; without it every name would come out + ;; with a leading dash. + cfg (assoc th/*system* ::rpc/module "main")] + (audit/prepare-rpc-event cfg mdata params result))) + +(t/deftest get-file-for-analysis-emits-audit-event + ;; Content reads stay traceable: with the skip gone, the call flows + ;; through `wrap-audit` like any other management method. `wrap-audit` + ;; itself is fixed at system init (no audit flags there), so assert the + ;; metadata flip plus the event shape through `prepare-rpc-event` with + ;; the real method metadata. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file-id (create-file! (:id profile) (:id project)) + [mdata _] (get-in th/*system* [:app.rpc/management-methods :get-file-for-analysis])] + (t/is (not (::audit/skip mdata))) + (let [out (th/management-command! {::th/type :get-file-for-analysis + :file-id file-id}) + result (:result out) + params (-> {:file-id file-id} + (assoc ::rpc/request-id (uuid/next) + ::rpc/request-at (ct/now)) + (with-meta {:app.http/request mock-request})) + cfg (assoc th/*system* ::rpc/module "management") + event (audit/prepare-rpc-event cfg mdata params result)] + (t/is (nil? (:error out))) + (t/is (= "management-get-file-for-analysis" (:name event))) + (t/is (= file-id (get-in event [:props :file-id]))) + (t/is (= (:id team) (get-in event [:props :team-id])))))) + +(t/deftest file-events-carry-scheduling-props + (let [profile (th/create-profile* 2 {:is-active true}) + team (th/create-team* 2 {:profile-id (:id profile)}) + project (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id team)}) + base {::rpc/profile-id (:id profile)}] + (t/testing "create-file" + (let [event (event-for :create-file + (assoc base + :project-id (:id project) + :name "Shop" + :is-shared false)) + props (:props event)] + (t/is (= "create-file" (:name event))) + (t/is (some? (:id props))) + (t/is (= "Shop" (:name props))) + (t/is (some? (:created-at props))) + (t/is (some? (:modified-at props))) + (t/is (= 0 (:revn props))))) + (t/testing "update-file" + ;; A file to edit, created through the command so the event + ;; below belongs to a real row. + (let [created (event-for :create-file + (assoc base + :project-id (:id project) + :name "Edit me" + :is-shared false)) + file-id (get-in created [:props :id]) + page-id (file-page-id file-id) + shape-id (uuid/random) + event (event-for :update-file + (assoc base + :id file-id + :session-id (uuid/random) + :revn 0 + :vern 0 + :features cfeat/supported-features + :changes [{: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})}])) + props (:props event)] + (t/is (= "update-file" (:name event))) + (t/is (= file-id (:id props))) + (t/is (= 1 (:revn props))) + (t/is (some? (:modified-at props))))) + (t/testing "share-link create and delete" + (let [created (event-for :create-file + (assoc base + :project-id (:id project) + :name "Shared" + :is-shared false)) + file-id (get-in created [:props :id]) + page-id (file-page-id file-id) + event (event-for :create-share-link + (assoc base + :file-id file-id + :pages #{page-id} + :who-comment "all" + :who-inspect "all")) + props (:props event) + slink-id (:share-id props)] + (t/is (= "create-share-link" (:name event))) + (t/is (= file-id (:file-id props))) + (t/is (some? slink-id)) + (let [event (event-for :delete-share-link + (assoc base :id slink-id)) + props (:props event)] + (t/is (= "delete-share-link" (:name event))) + (t/is (= file-id (:file-id props))) + (t/is (= slink-id (:share-id props)))))) + (t/testing "delete-file" + (let [created (event-for :create-file + (assoc base + :project-id (:id project) + :name "Doomed" + :is-shared false)) + file-id (get-in created [:props :id]) + event (event-for :delete-file + (assoc base :id file-id)) + props (:props event)] + (t/is (= "delete-file" (:name event))) + (t/is (= file-id (:id props))) + (t/is (some? (:deleted-at props))))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 305108003c..2d222f7add 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -6,11 +6,16 @@ (ns backend-tests.rpc-file-test (:require + [app.binfile.common :as bfc] [app.common.features :as cfeat] + [app.common.files.changes-builder :as pcb] + [app.common.files.tokens :as cfo] [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.shape :as cts] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -260,7 +265,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -409,7 +414,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -600,7 +605,7 @@ :object-id object-id :tag "frame" :media mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -1274,7 +1279,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] (t/is (nil? error)) (t/is (map? result))) @@ -1331,7 +1336,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] (t/is (nil? error)) (t/is (map? result))) @@ -1355,7 +1360,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] (t/is (nil? error)) (t/is (map? result))) @@ -1473,7 +1478,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] ;; (th/print-result! out) (t/is (nil? error)) @@ -1488,7 +1493,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - {:keys [error result] :as out} (th/command! data)] + {:keys [error result] :as out} (th/multipart-command! data)] ;; (th/print-result! out) (t/is (nil? error)) @@ -1986,7 +1991,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -2254,6 +2259,93 @@ (get-in out [:result :data :pages-index f-page :objects inst-id :component-file]))))))))) +(defn- prepare-tokens-library + "A shared library with a tokens lib, a file linked to it that uses it + as its tokens source, and another linked file that does not." + [profile] + (let [set-id (uuid/next) + theme-id (uuid/next) + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id :name "test-set")) + (ctob/add-theme (ctob/make-token-theme :id theme-id + :name "test-theme" + :sets #{"test-set"})) + (ctob/add-token set-id (ctob/make-token :name "token-radius" + :type :border-radius + :value 10))) + params {:profile-id (:id profile) + :project-id (:default-project-id profile)} + library (th/create-file* 1 (assoc params :is-shared true)) + file (th/create-file* 2 params) + other (th/create-file* 3 params)] + + (th/link-file-to-library* {:file-id (:id file) :library-id (:id library)}) + (th/link-file-to-library* {:file-id (:id other) :library-id (:id library)}) + + (update-file! + :file-id (:id library) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data library)) + (pcb/set-tokens-lib tokens-lib) + (:redo-changes))) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data file)) + (pcb/set-tokens-source (:id library)) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})) + (:redo-changes))) + + {:library library :file file :other other :tokens-lib tokens-lib})) + +(defn- check-tokens-absorbed + "The file is its own tokens source, with a copy of the tokens lib, and + keeps its tokens status; the other file is not changed." + [{:keys [file other tokens-lib]}] + (let [file-data (:data (bfc/get-file th/*system* (:id file))) + other-data (:data (bfc/get-file th/*system* (:id other)))] + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))) + (t/is (nil? (cfo/get-tokens-lib other-data))))) + +(t/deftest delete-shared-library-absorbs-its-tokens-lib + (let [profile (th/create-profile* 1) + {:keys [library file] :as prepared} (prepare-tokens-library profile) + out (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id library)})] + (t/is (nil? (:error out))) + + ;; The relations are removed at once, the absorption is done by the + ;; deletion task. + (t/is (empty? (th/db-query :file-library-rel {:library-file-id (:id library)}))) + (t/is (= (:id library) + (cfo/get-tokens-source (:data (bfc/get-file th/*system* (:id file)))))) + + (th/run-pending-jobs) + (check-tokens-absorbed prepared))) + +(t/deftest unpublish-shared-library-absorbs-its-tokens-lib + (let [profile (th/create-profile* 1) + {:keys [library] :as prepared} (prepare-tokens-library profile) + out (th/command! {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id library) + :is-shared false})] + (t/is (nil? (:error out))) + (check-tokens-absorbed prepared))) + (t/deftest deleted-files-permanently-delete (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) @@ -2994,3 +3086,56 @@ :file-id (:id file)})] (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :not-found)))) + +(t/deftest delete-file-audit-event-carries-team-id + ;; The event already carries the project id, so the resolver could find the + ;; team; the command has the team row and sets the id without a lookup. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + wrapped (th/command-raw! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (= (:id team) + (get-in (meta wrapped) [:app.loggers.audit/props :team-id]))))) + +(t/deftest create-file-audit-event-carries-its-id + ;; The command returns the new file but did not emit its id, so a consumer + ;; could not key a local projection. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + out (th/command! {::th/type :create-file + ::rpc/profile-id (:id profile) + :project-id (:id project) + :name "foobar" + :is-shared false})] + (t/is (th/success? out)) + (t/is (= (get-in out [:result :id]) + (get-in (meta (:result out)) [:app.loggers.audit/props :id]))) + (t/is (= (:id team) + (get-in (meta (:result out)) [:app.loggers.audit/props :team-id]))))) + +(t/deftest get-team-shared-files-skips-files-that-fail-to-load + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + get-file (deref #'files/get-file-with-summary) + broken (fn [cfg id] + (if (= id (:id file1)) + (throw (ex-info "broken file" {:type :assertion})) + (get-file cfg id))) + out (with-redefs-fn {#'files/get-file-with-summary broken} + #(th/command! {::th/type :get-team-shared-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile)}))] + (t/is (th/success? out)) + (t/is (= #{(:id file2)} (into #{} (map :id) (:result out)))))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 46206af06f..9863c0acbb 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -76,11 +76,11 @@ :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data1)] + (let [out (th/multipart-command! data1)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) - (let [out (th/command! data2)] + (let [out (th/multipart-command! data2)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -184,12 +184,12 @@ :path (th/tempfile "backend_tests/test_files/sample.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data1)] + (let [out (th/multipart-command! data1)] ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (contains? (:result out) :uri))) - (let [out (th/command! data2)] + (let [out (th/multipart-command! data2)] (t/is (nil? (:error out))) (t/is (contains? (:result out) :uri))) @@ -272,7 +272,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data) + out (th/multipart-command! data) error (:error out)] (t/is (nil? (:result out))) @@ -298,7 +298,7 @@ :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data1)] + (let [out (th/multipart-command! data1)] ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (contains? (:result out) :uri))) @@ -325,7 +325,7 @@ :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}}] - (let [out (th/command! data)] + (let [out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -341,7 +341,7 @@ (let [params {::th/type :get-file-object-thumbnails ::rpc/profile-id (:id profile) :file-id (:id file)} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) @@ -367,7 +367,7 @@ (-> media (assoc :path (th/tempfile "backend_tests/test_files/sample2.jpg")) (assoc :size 7923)))) - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -376,7 +376,7 @@ (-> media (assoc :path (th/tempfile "backend_tests/test_files/sample.jpg")) (assoc :size 312043)))) - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))))) @@ -402,7 +402,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -419,7 +419,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2 oid3]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -440,7 +440,7 @@ data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids []} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out))))) @@ -457,7 +457,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))))) @@ -480,7 +480,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -488,7 +488,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2 oid3]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -517,7 +517,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -525,7 +525,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -533,7 +533,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -563,7 +563,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -571,7 +571,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile2) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (some? (:error out))) (t/is (th/ex-info? (:error out))) (t/is (= :not-found (th/ex-type (:error out))))) @@ -606,7 +606,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -614,7 +614,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -653,7 +653,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -665,7 +665,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -674,7 +674,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile1) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (some? (:error out))) (t/is (th/ex-info? (:error out))) (t/is (= :not-found (th/ex-type (:error out))))) @@ -709,7 +709,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -721,7 +721,7 @@ :size 312043 :path (th/tempfile "backend_tests/test_files/sample.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -740,7 +740,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1 oid2]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -770,7 +770,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -784,7 +784,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids oids} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -813,7 +813,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -821,7 +821,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -849,7 +849,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out)))) @@ -857,7 +857,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid oid]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -891,7 +891,7 @@ :size 7923 :path (th/tempfile "backend_tests/test_files/sample2.jpg") :mtype "image/jpeg"}} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (map? (:result out))))) @@ -899,7 +899,7 @@ (let [data {::th/type :delete-file-object-thumbnails ::rpc/profile-id (:id profile) :object-ids [oid1]} - out (th/command! data)] + out (th/multipart-command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out)))) @@ -915,3 +915,39 @@ (t/is (= 1 (count rows2))) (t/is (nil? (:deleted-at (first rows2))))))) + +(t/deftest create-file-thumbnail-rejects-forged-media + ;; A transit body like {"~:media": {"~:path": ["~#path", "/etc/passwd"]}} + ;; decodes to this exact map. Without a multipart request it must fail at + ;; params validation, before any file is read. NOTE: plain command! on + ;; purpose, a multipart header would make it legitimate. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :create-file-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :revn 0 + :media {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest create-file-object-thumbnail-rejects-forged-media + ;; Same transit-forgery vector through object thumbnails. NOTE: plain + ;; command! on purpose, a multipart header would make it legitimate. + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :create-file-object-thumbnail + ::rpc/profile-id (:id profile) + :file-id (:id file) + :object-id "obj" + :media {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 90dabf62a0..4ef4533f7a 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -76,11 +76,11 @@ (when (uuid? session-id) (doseq [[idx chunk-data] (map-indexed vector chunks)] (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] (t/is (nil? (:error out)))))) session-id)) @@ -140,7 +140,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) + (t/is (= 8 (:freeze res)))) (let [params {::th/type :delete-font ::rpc/profile-id (:id prof) @@ -209,7 +209,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) + (t/is (= 8 (:freeze res)))) (let [params {::th/type :delete-font ::rpc/profile-id (:id prof) @@ -260,7 +260,7 @@ ;; freeze with hours 3 clock (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) + (t/is (= 8 (:freeze res)))) (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) :team-id team-id :id (-> out1 :result :id)} @@ -438,11 +438,11 @@ ;; Upload all real chunks except the last one (omit it so the session is incomplete) (doseq [[idx chunk-data] (map-indexed vector (butlast chunks))] (let [mfile (make-chunk-mfile chunk-data "font/ttf") - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] (t/is (nil? (:error out))))) (let [out (th/command! {::th/type :create-font-variant diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index c00930857c..500b241279 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -24,6 +24,7 @@ [clojure.set :as set] [clojure.test :as t] [cuerdas.core :as str] + [datoteka.fs :as fs] [mockery.core :refer [with-mocks]])) (t/use-fixtures :once (t/compose-fixtures @@ -575,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)] @@ -1989,6 +2018,9 @@ :type "action"}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are counted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (t/is (:called? @audit-mock)) @@ -2009,6 +2041,9 @@ :profile-id (:id prof)}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are asserted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (let [[_ event] (:call-args @audit-mock)] @@ -2028,6 +2063,9 @@ :type "action"}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are counted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (t/is (= 2 (:call-count @audit-mock))) @@ -2047,6 +2085,9 @@ :foo "bar"}}]} params (with-meta params {::http/request http-request}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are asserted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (let [[_ event] (:call-args @audit-mock)] @@ -2071,6 +2112,9 @@ params (with-meta params {::http/request (assoc http-request ::http/auth-key-id :admin-console)}) + ;; The profile setup above emits membership events; drop them so + ;; only the pushed events are asserted below. + _ (th/reset-mock! audit-mock) out (th/management-command! params)] (t/is (nil? (:error out))) (let [[_ event] (:call-args @audit-mock)] @@ -2128,3 +2172,18 @@ (t/is (th/success? out)) (let [[_cfg params] (:call-args @email-mock)] (t/is (= "" (:user-name params))))))) + +(t/deftest upload-organization-logo-rejects-forged-content + ;; Same transit-forgery vector through the management API: without a + ;; multipart request the wrapper must reject it at params validation. + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + params {::th/type :upload-organization-logo + ::rpc/profile-id (:id profile) + :organization-id (uuid/random) + :content {:filename "evil.png" + :path (fs/path "/etc/passwd") + :mtype "image/png" + :size 123}} + out (th/management-command! params)] + (t/is (th/ex-of-code? (:error out) :params-validation))))) diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 4b449af993..a55dcf95a4 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -9,6 +9,7 @@ [app.binfile.common :as bfc] [app.common.features :as cfeat] [app.common.pprint :as pp] + [app.common.time :as ct] [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.config :as cf] @@ -22,7 +23,8 @@ [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] - [datoteka.io :as io])) + [datoteka.io :as io] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -54,9 +56,9 @@ :size 7}} config (assoc cf/config :public-uri "https://example.com/penpot") out1 (binding [cf/config config] - (th/management-command! params)) + (th/multipart-management-command! params)) out2 (binding [cf/config config] - (th/management-command! params))] + (th/multipart-management-command! params))] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) (t/is (str/starts-with? (str (get-in out1 [:result :uri])) @@ -75,7 +77,7 @@ :path path :mtype "text/html" :size 27}} - out (th/management-command! params)] + out (th/multipart-management-command! params)] (t/is (some? (:error out))) (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :media-type-not-allowed (th/ex-code (:error out)))))) @@ -666,3 +668,68 @@ (t/is (vector? result)) (t/is (= 1 (count result))) (t/is (= "test" (:id (first result))))))) + +(t/deftest clone-template-audit-events-carry-team-id + ;; The per-file `create-file` events are built by hand, so they do not pass + ;; through the generic resolver: the command must copy the team id itself. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [prof (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id prof)}) + project (th/create-project* 1 {:profile-id (:id prof) + :team-id (:id team)}) + out (th/command! {::th/type :clone-template + ::rpc/profile-id (:id prof) + :project-id (:id project) + :template-id "test"})] + (t/is (th/success? out)) + (th/consume-sse (:result out)) + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "create-file" (:name %))))] + (t/is (seq events)) + (t/is (every? #(= (:id team) (get-in % [:props :team-id])) events))))))) + +(t/deftest duplicate-project-audit-events-carry-entity-ids + ;; The duplicated project and files are created by internal calls, so the + ;; command emits one create event per entity for a local projection. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 52 {:profile-id (:id profile)}) + project (th/create-project* 52 {:profile-id (:id profile) + :team-id (:id team)}) + _file (th/create-file* 52 {:profile-id (:id profile) + :project-id (:id project)}) + out (th/command! {::th/type :duplicate-project + ::rpc/profile-id (:id profile) + :project-id (:id project) + :name "copy"}) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(contains? #{"create-project" "create-file"} (:name %))))] + (t/is (th/success? out)) + (t/is (some #(and (= "create-project" (:name %)) + (= (get-in out [:result :id]) (get-in % [:props :id])) + (= (:id team) (get-in % [:props :team-id])) + (= "copy" (get-in % [:props :name]))) + events)) + (t/is (some #(and (= "create-file" (:name %)) + (string? (get-in % [:props :name])) + (int? (get-in % [:props :revn])) + (ct/inst? (get-in % [:props :modified-at]))) + events)) + (t/is (every? #(= (:id team) (get-in % [:props :team-id])) events)))))) + +(t/deftest upload-tempfile-rejects-forged-content + ;; Same transit-forgery vector through the management API: without a + ;; multipart request the wrapper must reject it at params validation. + (let [profile (th/create-profile* 1 {:is-active true}) + params {::th/type :upload-tempfile + ::rpc/profile-id (:id profile) + :content {:filename "evil.png" + :path (fs/path "/etc/passwd") + :mtype "image/png" + :size 123}} + out (th/management-command! params)] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index fb34396a09..3b42685b4d 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -73,7 +73,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -144,7 +144,7 @@ :is-local true :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -189,7 +189,7 @@ :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] (let [error (:error out) error-data (ex-data error)] @@ -391,11 +391,11 @@ ;; --- 1. Upload chunks --- (doseq [[idx chunk-data] (map-indexed vector chunks)] (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] (t/is (nil? (:error out))) (t/is (= session-id (:session-id (:result out)))) (t/is (= idx (:index (:result out)))))) @@ -438,11 +438,11 @@ mfile (make-chunk-mfile (first chunks) mtype) session-id (create-session! prof 1)] - (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) ;; First assemble succeeds; session row is marked as consumed afterwards (let [out1 (th/command! {::th/type :assemble-file-media-object @@ -482,11 +482,11 @@ :size 312043} ;; prof2 tries to upload a chunk into prof1's session - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof2) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof2) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))))) @@ -505,11 +505,11 @@ :size 312043}] ;; prof1 uploads a chunk into their own session - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof1) - :session-id session-id - :index 0 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof1) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out)))) ;; prof2 tries to assemble prof1's session via create-font-variant @@ -540,11 +540,11 @@ :mtype "image/jpeg" :size 312043}] - (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) ;; Assemble with a wrong mtype should fail validation (let [out (th/command! {::th/type :assemble-file-media-object @@ -573,11 +573,11 @@ :size 312043}] ;; Upload only 1 chunk - (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile}) + (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) ;; Assemble: session says 3 expected, only 1 stored → :missing-chunks (let [out (th/command! {::th/type :assemble-file-media-object @@ -606,18 +606,18 @@ mtype "image/jpeg" size (alength (first chunks))] - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (first chunks) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (first chunks) mtype)})] (t/is (nil? (:error out)))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (first chunks) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (first chunks) mtype)})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-already-exists (-> out :error ex-data :code)))) @@ -650,27 +650,27 @@ (t/is (= 3 (count chunks))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (nth chunks 0) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (nth chunks 0) mtype)})] (t/is (nil? (:error out)))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (nth chunks 0) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (nth chunks 0) mtype)})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-already-exists (-> out :error ex-data :code)))) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 1 - :content (make-chunk-mfile (nth chunks 1) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 1 + :content (make-chunk-mfile (nth chunks 1) mtype)})] (t/is (nil? (:error out)))) ;; The mapping table holds exactly the two distinct indices: the @@ -735,11 +735,11 @@ :mtype "image/jpeg" :size 312043} session-id (create-session! prof 1) - upload-out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + upload-out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error upload-out))) (let [assemble-out (th/command! {::th/type :assemble-file-media-object @@ -788,21 +788,21 @@ :size 312043}] ;; index == total-chunks (out of range) - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 2 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 2 + :content mfile})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))) ;; negative index - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index -1 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index -1 + :content mfile})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))))) @@ -821,11 +821,11 @@ mtype "image/jpeg"] ;; 312043 bytes exceeds the mocked 1024-byte cap: rejected - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (first chunks) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (first chunks) mtype)})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-too-large (-> out :error ex-data :code)))) @@ -835,11 +835,11 @@ session-id])))) ;; A chunk exactly at the cap still uploads fine - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})] (t/is (nil? (:error out))))))) (t/deftest chunked-upload-sessions-per-profile-quota @@ -875,11 +875,11 @@ :path source-path :mtype "image/jpeg" :size 312043} - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (let [row (th/db-exec-one! ["select session_id, object_id, chunk_index from upload_session_chunk where session_id = ?" @@ -904,19 +904,19 @@ :path source-path :mtype "image/jpeg" :size 312043} - out1 (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out1 (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out1))) (let [before (:count (th/db-exec-one! ["select count(*) from storage_object"])) - out2 (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out2 (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out2))) (t/is (= :validation (-> out2 :error ex-data :type))) (t/is (= :chunk-already-exists (-> out2 :error ex-data :code))) @@ -937,11 +937,11 @@ (th/db-exec! ["insert into upload_session_chunk (session_id, chunk_index, object_id) values (?, ?, null)" session-id 0]) (let [before (:count (th/db-exec-one! ["select count(*) from storage_object"])) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out))) (t/is (= :validation (-> out :error ex-data :type))) (t/is (= :chunk-already-exists (-> out :error ex-data :code))) @@ -965,21 +965,21 @@ (if (compare-and-set! failed? false true) (throw (ex-info "link boom" {})) (orig pool object-id session-id index)))}] - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out)))) ;; the failed link left no reservation behind (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" session-id])))) ;; retrying the same index in the same session succeeds - (let [out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + (let [out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" session-id])))))))) @@ -999,11 +999,11 @@ :path source-path :mtype "image/jpeg" :size 312043} - out1 (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out1 (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out1))) (let [assemble-out (th/command! {::th/type :assemble-file-media-object @@ -1024,11 +1024,11 @@ ;; uploading to the consumed session fails without creating an object (let [before (:count (th/db-exec-one! ["select count(*) from storage_object"])) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))) (t/is (= :object-not-found (-> out :error ex-data :code))) @@ -1054,11 +1054,11 @@ :path source-path :mtype "image/jpeg" :size 312043} - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (let [object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" @@ -1182,13 +1182,13 @@ :mtype "image/jpeg" :size 312043} sent-id (uuid/next) - out (th/command! {::th/type :upload-file-media-object - ::rpc/profile-id (:id prof) - :file-id (:id file) - :is-local true - :name "testfile" - :content mfile - :id sent-id})] + out (th/multipart-command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile + :id sent-id})] (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)))) @@ -1226,3 +1226,60 @@ (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 media-audit-event-carries-team-id + ;; The media event carries the file id, so the resolver could find the team; + ;; the command already resolved the team for the quota check and sets it. + (let [prof (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id prof)}) + project (th/create-project* 1 {:profile-id (:id prof) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:id project) + :is-shared false}) + mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + out (th/multipart-command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile})] + (t/is (th/success? out)) + (t/is (= (:id team) + (get-in (meta (:result out)) [:app.loggers.audit/replace-props :team-id]))))) + +(t/deftest upload-file-media-object-rejects-forged-content + ;; A transit body like {"~:content": {"~:path": ["~#path", "/etc/passwd"]}} + ;; decodes to this exact map. Without a multipart request it must fail at + ;; params validation, before any file is read. + (let [prof (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + out (th/command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + +(t/deftest upload-chunk-rejects-forged-content + ;; Same transit-forgery vector through the chunk primitive: without a + ;; multipart request the wrapper must reject it at params validation. + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id (uuid/random) + :index 0 + :content {:filename "chunk" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index fbdef2f160..b0cda30dd8 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -140,12 +140,12 @@ (t/is (= "dark" (get-in out [:result :theme]))))) (t/testing "update photo" - (let [data {::th/type :update-profile-photo - ::rpc/profile-id (:id profile) - :file {:filename "sample.jpg" - :size 123123 - :path (th/tempfile "backend_tests/test_files/sample.jpg") - :mtype "image/jpeg"}} + (let [data (th/with-multipart {::th/type :update-profile-photo + ::rpc/profile-id (:id profile) + :file {:filename "sample.jpg" + :size 123123 + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg"}}) out (th/command! data)] ;; (th/print-result! out) @@ -1802,3 +1802,43 @@ (t/is (th/success? out)) (t/is (= (:id member) event-pid)) (t/is (not= (:id owner) event-pid)))))) + +(t/deftest delete-profile-audit-event-carries-profile-data + ;; The event used to carry empty props because every param is namespaced and + ;; clean-props drops them; the projection needs the deleted identity. + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/command-raw! {::th/type :delete-profile + ::rpc/profile-id (:id profile)}) + props (:app.loggers.audit/props (meta out))] + (t/is (= (:id profile) (:id props))) + (t/is (= "profile1.test@nodomain.com" (:email props))) + (t/is (= "Profile 1" (:fullname props))) + (t/is (ct/inst? (:created-at props))) + (t/is (ct/inst? (:modified-at props))) + (t/is (ct/inst? (:deleted-at props))))) + +(t/deftest delete-profile-is-idempotent + ;; Deleting an already-deleted profile (client retry after a lost response) + ;; succeeds: the row is still there, only marked. + (let [profile (th/create-profile* 1 {:is-active true}) + params {::th/type :delete-profile + ::rpc/profile-id (:id profile)} + first-out (th/command-raw! params)] + (t/is (th/success? first-out)) + (let [second-out (th/command-raw! params)] + (t/is (th/success? second-out)) + (t/is (= (:id profile) + (get-in (meta second-out) [:app.loggers.audit/props :id])))))) + +(t/deftest update-profile-photo-rejects-forged-file + ;; A transit body like {"~:file": {"~:path": ["~#path", "/etc/passwd"]}} + ;; decodes to this exact map. Without a multipart request it must fail at + ;; params validation, before any file is read. + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :update-profile-photo + ::rpc/profile-id (:id prof) + :file {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj index f5855a43e8..c27bb31868 100644 --- a/backend/test/backend_tests/rpc_project_test.clj +++ b/backend/test/backend_tests/rpc_project_test.clj @@ -274,3 +274,16 @@ (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 create-project-audit-event-carries-its-id + ;; The command returns the new project but did not emit its id, so a consumer + ;; could not key a local projection. + (let [profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + out (th/command! {::th/type :create-project + ::rpc/profile-id (:id profile) + :team-id (:id team) + :name "test project"})] + (t/is (th/success? out)) + (t/is (= (get-in out [:result :id]) + (get-in (meta (:result out)) [:app.loggers.audit/props :id]))))) diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index 84493e1ed4..cd49f85042 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -445,6 +445,7 @@ (t/is (true? true) "deduped storage counted once, under quota")))) (t/deftest media-upload-enforces-storage-quote + ;; TEMPORARILY DISABLED (2026-10-19), see body. (with-mocks [mock {:target 'app.config/get :return (th/config-get-mock {:quotes-media-storage-bytes-per-team 100})}] @@ -467,14 +468,106 @@ :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] - ;; 312043 bytes > 100 byte limit → should be rejected - (t/is (not (th/success? out))) - (let [error (:error out)] - (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)))))))) + ;; TEMPORARILY DISABLED (2026-10-19): upload path no longer checks + ;; media-storage-bytes-per-team, so even over quota the upload + ;; succeeds. TODO: restore rejection with cheaper implementation. + (t/is (th/success? out))))) + +;; --- SNAPSHOTS (VERSIONS) QUOTES + +(defn- check-snapshot-ok + "Save a version of the file and check that it was created." + [profile file label] + (let [out (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :label label})] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (= label (get-in out [:result :label]))) + out)) + +(defn- check-snapshot-ko + "Try to save a version of the file and check that the quota refused it." + [profile file label target] + (let [out (th/command! {::th/type :create-file-snapshot + ::rpc/profile-id (:id profile) + :file-id (:id file) + :label label}) + error (:error out)] + ;; (th/print-result! out) + (t/is (not (th/success? out))) + (t/is (= :restriction (th/ex-type error))) + (t/is (= :max-quote-reached (th/ex-code error))) + (t/is (= target (:target (ex-data error)))) + out)) + +(t/deftest snapshots-per-file-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-snapshots-per-file 2 + :quotes-snapshots-per-team 1000})}] + + (let [profile (th/create-profile* 1) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:default-team-id profile)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false})] + + (check-snapshot-ok profile file "version1") + (check-snapshot-ok profile file "version2") + (check-snapshot-ko profile file "version3" "snapshots-per-file")))) + +(t/deftest snapshots-per-team-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-snapshots-per-file 1000 + :quotes-snapshots-per-team 3})}] + + (let [profile (th/create-profile* 1) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:default-team-id profile)}) + file-1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false}) + file-2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false})] + + ;; the quota adds up the versions of every file of the team + (check-snapshot-ok profile file-1 "version1") + (check-snapshot-ok profile file-2 "version2") + (check-snapshot-ok profile file-1 "version3") + (check-snapshot-ko profile file-2 "version4" "snapshots-per-team")))) + +(t/deftest snapshots-quote-freed-on-delete + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-snapshots-per-file 2 + :quotes-snapshots-per-team 1000})}] + + (let [profile (th/create-profile* 1) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:default-team-id profile)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project) + :is-shared false}) + snapshot (:result (check-snapshot-ok profile file "version1"))] + + (check-snapshot-ok profile file "version2") + (check-snapshot-ko profile file "version3" "snapshots-per-file") + + ;; deleting a version takes it out of the count right away + (let [out (th/command! {::th/type :delete-file-snapshot + ::rpc/profile-id (:id profile) + :id (:id snapshot)})] + (t/is (nil? (:error out))) + (t/is (true? (:result out))) + + (check-snapshot-ok profile file "version4"))))) (t/deftest binfile-jobs-per-profile-quote (with-mocks [mock {:target 'app.config/get @@ -550,3 +643,4 @@ (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/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 7781605860..b1bab1acb6 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -6,8 +6,10 @@ (ns backend-tests.rpc-team-test (:require + [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] + [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -17,6 +19,7 @@ [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.teams :as teams] + [app.rpc.helpers :as rph] [app.storage :as sto] [app.tokens :as tokens] [backend-tests.helpers :as th] @@ -524,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]))) @@ -538,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 @@ -566,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]))) @@ -580,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]))) @@ -909,7 +945,7 @@ :name "testfile" :content mfile} - out (th/command! params)] + out (th/multipart-command! params)] (t/is (nil? (:error out)))) (let [params {::th/type :delete-team @@ -1473,6 +1509,20 @@ (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) +(t/deftest update-team-photo-rejects-forged-file + ;; Same transit-forgery vector as profile photos: without a multipart + ;; request the wrapper must reject it at params validation. + (let [prof (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id prof)}) + out (th/command! {::th/type :update-team-photo + ::rpc/profile-id (:id prof) + :team-id (:id team) + :file {:filename "evil.jpg" + :size 123 + :path (fs/path "/etc/passwd") + :mtype "image/jpeg"}})] + (t/is (th/ex-of-code? (:error out) :params-validation)))) + (t/deftest leaver-loses-file-access ;; GHSA-v9r9-h77c-55m2: leaving the team must revoke the file and ;; project roles granted in that team. @@ -1784,3 +1834,195 @@ ::rpc/profile-id (:id stranger)})] (t/is (th/success? out)) (t/is (nil? (some #(= (:id project) (:id %)) (:result out)))))))) + +(t/deftest create-team-emits-add-team-member-audit-event + ;; Team creation adds the owner through add-profile-to-team!, which used to + ;; leave no membership event. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 21 {:profile-id (:id owner)}) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))) + (filter #(= (:id team) (get-in % [:props :team-id]))))] + (t/is (= 1 (count events))) + (t/is (= (:id owner) (get-in (first events) [:props :member-id]))) + (t/is (true? (get-in (first events) [:props :is-owner]))))))) + +(t/deftest add-team-member-event-skipped-when-membership-exists + ;; Adding an existing member with on-conflict-do-nothing inserts nothing and + ;; must not report a membership that already exists. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 22 {:profile-id (:id owner)})] + (th/reset-mock! audit-mock) + (db/tx-run! th/*system* + (fn [cfg] + (teams/add-profile-to-team! cfg + {:team-id (:id team) + :profile-id (:id owner)} + {::db/on-conflict-do-nothing? true}))) + (t/is (empty? (:call-args-list @audit-mock))))))) + +(t/deftest no-add-team-member-event-without-audit-log + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + _team (th/create-team* 23 {:profile-id (:id owner)}) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))))] + (t/is (empty? events))))) + +(t/deftest add-team-member-event-carries-derived-role + ;; `team_profile_rel` has no role column, so no caller can pass it: the + ;; emitter derives it from the flags, inverse of permissions-for-role. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 26 {:profile-id (:id owner)}) + editor (th/create-profile* 27 {:is-active true}) + admin (th/create-profile* 28 {:is-active true}) + viewer (th/create-profile* 29 {:is-active true})] + (db/tx-run! th/*system* + (fn [cfg] + (doseq [[member role] [[editor :editor] + [admin :admin] + [viewer :viewer]]] + (teams/add-profile-to-team! + cfg + (merge {:team-id (:id team) + :profile-id (:id member)} + (get types.team/permissions-for-role role)))))) + (let [by-member (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))) + (d/index-by #(get-in % [:props :member-id])))] + (t/is (= :owner (get-in by-member [(:id owner) :props :role]))) + (t/is (= :editor (get-in by-member [(:id editor) :props :role]))) + (t/is (= :admin (get-in by-member [(:id admin) :props :role]))) + (t/is (= :viewer (get-in by-member [(:id viewer) :props :role])))))))) + +(t/deftest add-team-member-event-carries-request-context + ;; Sibling events built by `prepare-rpc-event` carry the request context; + ;; the hand-built membership event must not lose it when rpc params travel. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [owner (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + team (th/create-team* 30 {:profile-id (:id owner)})] + (th/reset-mock! audit-mock) + (db/tx-run! th/*system* + (fn [cfg] + (teams/add-profile-to-team! + cfg + (merge {:team-id (:id team) + :profile-id (:id member)} + (get types.team/permissions-for-role :editor)) + nil + {:rpc-params {::rpc/profile-id (:id owner) + ::rpc/request-id "req-1"}}))) + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))))] + (t/is (= 1 (count events))) + (t/is (= "req-1" (get-in (first events) [:context :request-id])))))))) + +(t/deftest invitation-direct-add-attributes-event-to-inviter + ;; With email verification off, inviting an existing profile adds the + ;; member directly; the event belongs to the inviter, not the member. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (with-redefs [cf/flags #{:audit-log}] + (let [owner (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + team (th/create-team* 31 {:profile-id (:id owner)})] + (th/reset-mock! audit-mock) + (let [out (th/command! {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails [(:email member)]})] + (t/is (th/success? out)) + (let [events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "add-team-member" (:name %))) + (filter #(= (:id member) (get-in % [:props :member-id]))))] + (t/is (= 1 (count events))) + (t/is (= (:id owner) (:profile-id (first events)))) + (t/is (= :editor (get-in (first events) [:props :role]))))))))) + +(t/deftest create-team-audit-event-carries-created-at + ;; The projection keys the new team on the event id and needs its creation + ;; time from the same event. + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/command-raw! {::th/type :create-team + ::rpc/profile-id (:id profile) + :name "team"}) + props (:app.loggers.audit/props (meta out))] + (t/is (= (:id out) (:id props))) + (t/is (= (:created-at out) (:created-at props))) + (t/is (ct/inst? (:created-at props))) + (t/is (= (:modified-at out) (:modified-at props))) + (t/is (ct/inst? (:modified-at props))))) + +(t/deftest delete-team-audit-event-carries-entity-data + ;; The projection marks the team deleted from this single event, so it needs + ;; the identity, the name and the timestamps. + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 31 {:profile-id (:id profile)}) + out (th/command-raw! {::th/type :delete-team + ::rpc/profile-id (:id profile) + :id (:id team)}) + props (:app.loggers.audit/props (meta out))] + ;; The API response is unchanged: the command still returns nil. + (t/is (nil? (rph/unwrap out))) + (t/is (= (:id team) (:id props))) + (t/is (= (:name team) (:name props))) + (t/is (= (:created-at team) (:created-at props))) + (t/is (ct/inst? (:modified-at props))) + (t/is (ct/inst? (:deleted-at props))))) + +(t/deftest create-team-with-invitations-audit-event-carries-team-id + ;; The team is created by an internal call, so both the command event and + ;; the manual event must carry its id for a projection to key it. + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil} + email-mock {:target 'app.email/send :return nil}] + (with-redefs [cf/flags (conj cf/flags :audit-log)] + (let [profile (th/create-profile* 1 {:is-active true}) + out (th/command-raw! {::th/type :create-team-with-invitations + ::rpc/profile-id (:id profile) + :name "team" + :emails #{"invitee@example.com"} + :role :editor}) + props (:app.loggers.audit/props (meta out)) + events (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "create-team" (:name %))))] + (t/is (= (:id out) (:id props))) + (t/is (= 1 (count events))) + (t/is (= (:id out) (get-in (first events) [:props :id]))))))) + +(t/deftest update-team-member-role-to-owner-names-demoted-member + ;; Promoting a member to owner demotes the actor; the event names them so a + ;; projection can keep a single owner. + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + team (th/create-team* 82 {:profile-id (:id owner)})] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + (let [out (th/command-raw! {::th/type :update-team-member-role + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id editor) + :role :editor}) + props (:app.loggers.audit/props (meta out))] + (t/is (nil? (:prev-owner-id props)))) + (let [out (th/command-raw! {::th/type :update-team-member-role + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id editor) + :role :owner}) + props (:app.loggers.audit/props (meta out))] + (t/is (= (:id owner) (:prev-owner-id props)))))) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index 5b15d46111..de5227aeb2 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -374,3 +374,29 @@ (t/is (nil? (:result out))) (let [rows (th/db-exec! ["select * from webhook"])] (t/is (= 0 (count rows))))))))))) + +(t/deftest webhook-audit-events-carry-team-id + ;; `update-webhook` and `delete-webhook` have no team id in their params, so + ;; the command copies it from the stored webhook into the audit props. + (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] + (let [prof (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id prof)}) + whook (-> (th/command! {::th/type :create-webhook + ::rpc/profile-id (:id prof) + :team-id (:id team) + :uri (u/uri "http://example.com") + :mtype "application/json"}) + :result) + upd (th/command! {::th/type :update-webhook + ::rpc/profile-id (:id prof) + :id (:id whook) + :uri (u/uri "http://example.com/updated") + :mtype "application/json" + :is-active true}) + del (th/command-raw! {::th/type :delete-webhook + ::rpc/profile-id (:id prof) + :id (:id whook)})] + (t/is (= (:id team) + (get-in (meta (:result upd)) [:app.loggers.audit/props :team-id]))) + (t/is (= (:id team) + (get-in (meta del) [:app.loggers.audit/props :team-id])))))) diff --git a/backend/test/backend_tests/scripts_decode_file_data_test.clj b/backend/test/backend_tests/scripts_decode_file_data_test.clj new file mode 100644 index 0000000000..e14618542f --- /dev/null +++ b/backend/test/backend_tests/scripts_decode_file_data_test.clj @@ -0,0 +1,82 @@ +;; 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.scripts-decode-file-data-test + (:require + [app.common.json :as json] + [app.common.uuid :as uuid] + [app.scripts.decode-file-data :as dfd] + [app.util.blob :as blob] + [clojure.java.io :as io] + [clojure.test :as t]) + (:import + java.util.Base64)) + +(defn- sample-file-data + [] + (let [p1 (uuid/next) + p2 (uuid/next)] + {:pages [p1 p2] + :pages-index + {p1 {:id p1 :name "Accueil1" :objects {:a 1 :b 2}} + p2 {:id p2 :name "Contact" :objects {:c 3}}} + :components {(uuid/next) {:name "comp"}} + :colors {(uuid/next) {:name "red"}} + :typographies {}})) + +(t/deftest sanitize-converts-special-types + (let [id (uuid/next) + out (dfd/sanitize {:id id + :kw :pages-index + :when (java.util.Date. 0) + :tags #{:a :b} + :raw (.getBytes "hi" "UTF-8")})] + (t/is (= (str id) (get out "id"))) + (t/is (= "pages-index" (get out "kw"))) + (t/is (string? (get out "when"))) + (t/is (= ["a" "b"] (sort (get out "tags")))) + (t/is (= {"$bytes" (.encodeToString (Base64/getEncoder) (.getBytes "hi" "UTF-8"))} + (get out "raw"))))) + +(t/deftest sanitize-keeps-integers-as-integers + (t/is (= 2 (dfd/sanitize 2))) + (t/is (= 2.5 (dfd/sanitize 2.5)))) + +(t/deftest blob-roundtrip-all-versions-to-json + (doseq [version [1 3 4 5]] + (let [data (sample-file-data) + encoded (blob/encode data {:version version}) + payload (dfd/blob->json encoded) + parsed (json/decode payload)] + (t/is (map? parsed) (str "version " version " produces a JSON map")) + (t/is (= 2 (count (get parsed "pages"))) (str "version " version " keeps pages"))))) + +(t/deftest summary-lists-pages-in-order-with-object-counts + (let [data (sample-file-data) + summary (dfd/summarize-data data) + pages (:pages summary)] + (t/is (= 2 (count pages))) + (t/is (= ["Accueil1" "Contact"] (mapv :name pages))) + (t/is (= [2 1] (mapv :objects pages))) + (t/is (= {:pages 2 :components 1 :colors 1 :typographies 0} + (:stats summary))) + (let [pages-json (json/decode (dfd/file-data-pages-json data))] + (t/is (= ["Accueil1" "Contact"] (mapv #(get % "name") pages-json)))))) + +(t/deftest cli-decodes-blob-file-to-json + (let [data (sample-file-data) + tmp-in (java.io.File/createTempFile "blob" ".bin") + tmp-out (java.io.File/createTempFile "out" ".json") + _ (.deleteOnExit tmp-in) + _ (.deleteOnExit tmp-out) + _ (with-open [os (io/output-stream tmp-in)] + (.write os ^bytes (blob/encode data {:version 5}))) + _ (dfd/-main "--input" (.getAbsolutePath tmp-in) + "--output" (.getAbsolutePath tmp-out) + "--format" "summary") + parsed (json/decode (slurp tmp-out))] + (t/is (== 2 (get-in parsed ["stats" "pages"]))) + (t/is (= ["Accueil1" "Contact"] (mapv #(get % "name") (get parsed "pages")))))) diff --git a/backend/test/backend_tests/srepl_main_test.clj b/backend/test/backend_tests/srepl_main_test.clj index b44c20bf57..1d131d1fe3 100644 --- a/backend/test/backend_tests/srepl_main_test.clj +++ b/backend/test/backend_tests/srepl_main_test.clj @@ -11,7 +11,8 @@ [app.srepl.main :as srepl] [app.system :as sys] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -150,3 +151,57 @@ #(srepl/process-deleted-files-cascade :ids [(:id file)]))] (t/is (= [{:object :file :id (:id file)}] (mapv #(select-keys % [:object :id]) (params-of submitted)))))))))) + +(t/deftest delete-profile-emits-identifiable-audit-event + ;; SREPL has no actor, so the event profile stays zero; the subject id in + ;; props is what makes the row attributable. Both insert and cascade are + ;; mocked, so this has no side effects. + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil} + wrk-mock {:target 'app.jobs/invoke :return nil}] + (let [profile (th/create-profile* 1 {:is-active true})] + (srepl/delete-profile! (str (:id profile))) + (let [events (->> (:call-args-list @audit-mock) + (map second))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "delete-profile" (:name event))) + (t/is (= "action" (:type event))) + ;; No actor on SREPL calls; insert defaults the missing profile + ;; to the zero one. + (t/is (nil? (:profile-id event))) + (t/is (= (:id profile) (get-in event [:props :id])))))))) + +(t/deftest delete-team-emits-identifiable-audit-event + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil} + wrk-mock {:target 'app.jobs/invoke :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 95 {:profile-id (:id profile)})] + (srepl/delete-team! (str (:id team))) + (let [events (->> (:call-args-list @audit-mock) + (map second))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "delete-team" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id team) (get-in event [:props :id]))) + (t/is (= "explicit call to delete-team!" + (get-in event [:context :cause])))))))) + +(t/deftest restore-profile-emits-identifiable-audit-event + ;; SREPL has no actor, so the event profile stays zero; the subject id in + ;; props is what makes the row attributable. + (with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil} + wrk-mock {:target 'app.jobs/invoke :return nil}] + (with-redefs [sys/system th/*system*] + (let [profile (th/create-profile* 1 {:is-active true})] + (srepl/restore-profile! (str (:id profile))) + (let [events (->> (:call-args-list @audit-mock) + (map second))] + (t/is (= 1 (count events))) + (let [event (first events)] + (t/is (= "restore-profile" (:name event))) + (t/is (= "action" (:type event))) + ;; No actor on SREPL calls; insert defaults the missing profile + ;; to the zero one. + (t/is (nil? (:profile-id event))) + (t/is (= (:id profile) (get-in event [:props :id]))))))))) diff --git a/backend/test/backend_tests/storage_gc_deleted_test.clj b/backend/test/backend_tests/storage_gc_deleted_test.clj index 9e1f87f723..ecd805af73 100644 --- a/backend/test/backend_tests/storage_gc_deleted_test.clj +++ b/backend/test/backend_tests/storage_gc_deleted_test.clj @@ -10,6 +10,7 @@ [app.common.uuid :as uuid] [app.db :as db] [app.storage :as sto] + [app.storage.impl :as impl] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -91,3 +92,66 @@ (t/testing "the object of the next chunk is gone" (t/is (nil? (find-object last-id)))))) + +(t/deftest failed-batch-is-not-visited-again-in-the-same-run + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + + ;; A full first batch of deletable objects. + batch1 (mapv (fn [_] (put-object! (ct/in-past "30m"))) (range chunk-size)) + + ;; The blocked object leads a second full batch, in `deleted_at` order + ;; after the first one: the whole batch fails and must be skipped, and + ;; the cursor must not come back to any of its objects. + blocked (blocked-object! (:id file)) + fillers (mapv (fn [_] (put-object! (ct/in-past "20m"))) (range (dec chunk-size))) + + ;; Objects after the failed batch: the next read batch starts right + ;; after the `(deleted_at, id)` of the last row of the failed one. + batch3 (mapv (fn [_] (put-object! (ct/in-past "5m"))) (range 5)) + + result (th/run-task! :storage-gc-deleted {})] + + (t/testing "the first and the last batches are fully deleted" + (t/is (= (+ (count batch1) (count batch3)) (:deleted result)))) + + (t/testing "the objects of the first batch are gone" + (t/is (every? #(nil? (find-object (:id %))) batch1))) + + (t/testing "the objects of the failed batch are still pending" + (t/is (some? (find-object (:id blocked)))) + (t/is (every? #(some? (find-object (:id %))) fillers))) + + (t/testing "the objects after the failed batch are gone" + (t/is (every? #(nil? (find-object (:id %))) batch3))))) + +(t/deftest physical-failure-rolls-back-the-chunk-and-continues + "Pins the chunk rollback semantics: the physical delete of the first + chunk fails (mocked) after its rows were already deleted inside the + transaction, so the transaction must roll back (the rows stay pending) + while the run skips the failed chunk and deletes the rest." + (let [;; A full first chunk of deletable objects, older in + ;; `deleted_at` order than the rest, so the failing physical + ;; delete hits exactly this chunk. + old (mapv (fn [_] (put-object! (ct/in-past "30m"))) (range chunk-size)) + recent (mapv (fn [_] (put-object! (ct/in-past "5m"))) (range 5)) + + failing (atom true) + orig impl/del-objects-in-bulk + result (with-redefs [impl/del-objects-in-bulk + (fn [backend ids] + (if @failing + (do (reset! failing false) + (throw (ex-info "simulated backend failure" {}))) + (orig backend ids)))] + (th/run-task! :storage-gc-deleted {}))] + + (t/testing "the run finishes and only the objects after the failed chunk are deleted" + (t/is (= (count recent) (:deleted result)))) + + (t/testing "the objects of the failed chunk are still pending" + (t/is (every? #(some? (find-object (:id %))) old))) + + (t/testing "the objects after the failed chunk are gone" + (t/is (every? #(nil? (find-object (:id %))) recent))))) diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index ba7c2bb914..3787db7c1f 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -17,6 +17,7 @@ [app.storage :as sto] [app.storage.fs :as-alias sto.fs] [app.storage.gc-deleted :as gcd] + [app.storage.gc-touched :as gc-touched] [app.storage.impl :as impl] [app.storage.s3 :as-alias sto.s3] [app.storage.schema :as stsch] @@ -249,8 +250,8 @@ :name "testfile" :content mfile} - out1 (th/command! params) - out2 (th/command! params)] + out1 (th/multipart-command! params) + out2 (th/multipart-command! params)] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) @@ -307,11 +308,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (assert (nil? (:error out))) session-id)) @@ -356,7 +357,7 @@ :font-style "normal" :uploads {"font/ttf" session-id}} - out1 (th/command! params1) + out1 (th/multipart-command! params1) out2 (th/command! params2)] ;; (th/print-result! out) @@ -367,8 +368,10 @@ ;; run the touched gc task (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] - (t/is (= 5 (:freeze res))) - (t/is (= 1 (:delete res))) + (t/is (= 6 (:freeze res))) + ;; the consumed-but-not-yet-purged session keeps its mapping, + ;; so its chunk freezes instead of being marked + (t/is (= 0 (:delete res))) (let [result-1 (:result out1) result-2 (:result out2)] @@ -421,8 +424,8 @@ :name "testfile" :content mfile} - out1 (th/command! params) - out2 (th/command! params)] + out1 (th/multipart-command! params) + out2 (th/multipart-command! params)] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) @@ -690,9 +693,9 @@ (t/is (= 26 (count ids))) (th/db-exec! ["update storage_object set deleted_at = ?" (ct/now)]) - (let [orig @#'gcd/process-chunk + (let [orig @#'gcd/process-chunk-deletion calls (atom 0)] - (alter-var-root #'gcd/process-chunk + (alter-var-root #'gcd/process-chunk-deletion (constantly (fn [& args] (when (= 2 (swap! calls inc)) (throw (ex-info "boom" {}))) @@ -705,7 +708,7 @@ (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))))))) + (alter-var-root #'gcd/process-chunk-deletion (constantly orig))))))) (t/deftest objects-gc-task-skip-delay (let [storage (-> (:app.storage/storage th/*system*) @@ -726,8 +729,8 @@ :is-local true :name "testfile" :content mfile} - out1 (th/command! params) - out2 (th/command! params)] + out1 (th/multipart-command! params) + out2 (th/multipart-command! params)] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) @@ -990,25 +993,6 @@ (:id object)])] (t/is (= 1 (:count row)))))) -(t/deftest gc-deleted-gives-up-after-max-attempts - (let [storage (-> (:app.storage/storage th/*system*) - (configure-storage-backend)) - content (sto/content "content") - object (sto/put-object! storage {::sto/content content - :content-type "text/plain"})] - - (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1}) - :deletion_attempts 6} - {:id (:id object)}) - - (with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk - :return (fn [_ ids] (set ids))}] - (let [res (th/run-task! :storage-gc-deleted {})] - (t/is (= 0 (:deleted res))))) - - (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] - (t/is (= 0 (:count row)))))) - (t/deftest dedup-reuses-existing-blob-with-touch (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) @@ -1101,11 +1085,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) @@ -1142,11 +1126,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" @@ -1177,6 +1161,167 @@ (t/is (= 1 (:deleted res)))) (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object"])))))) +(t/deftest upload-session-chunk-frozen-while-referenced + ;; A chunk whose session mapping is still alive must be frozen, not + ;; marked for deletion: the reference scan owns the liveness decision. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + (t/is (nil? (:error out))) + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))) + + ;; the session is neither consumed nor stalled: the mapping is alive + (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)))) + + ;; frozen: untouched, not marked, mapping intact + (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])))) + (t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])))) + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))))) + +(t/deftest upload-session-freeze-reverify + ;; The freeze recheck runs under row locks: an object whose mapping + ;; disappeared (plus a concurrent touch) after the first check is + ;; reclassified to deletion instead of swallowing the touch. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" + session-id])) + check @#'gc-touched/has-upload-session-chunk-refs? + verify @#'gc-touched/verify-frozen] + (t/is (nil? (:error out))) + + ;; mapping alive: stays frozen + (let [[frozen deletable] (db/transact! th/*pool* (fn [conn] (verify conn check "upload-session" [{:id object-id}])))] + (t/is (= #{object-id} frozen)) + (t/is (= #{} deletable))) + + ;; simulate a purge that committed after the first check: mapping + ;; gone, touch bumped + (th/db-exec! ["delete from upload_session_chunk where session_id = ?" session-id]) + (th/db-exec! ["update storage_object set touched_at = ? where id = ?" (ct/now) object-id]) + + ;; recheck moves it to deletion + (let [[frozen deletable] (db/transact! th/*pool* (fn [conn] (verify conn check "upload-session" [{:id object-id}])))] + (t/is (= #{} frozen)) + (t/is (= #{object-id} deletable))))) + +(t/deftest upload-session-gc-deleted-skips-live-mapping + ;; A row marked for deletion while its mapping is still alive must NOT + ;; be reaped silently: the chunk is skipped loud (logged) and retried + ;; once objects-gc purges the session. This pins the mark-time + ;; invariant instead of breaking sessions quietly. + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" + session-id]))] + (t/is (nil? (:error out))) + + ;; force the anomaly: mark deleted while the mapping is alive + (th/db-exec! ["update storage_object set deleted_at = ? where id = ?" (ct/now) object-id]) + + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 0 (:deleted res)))) + + ;; row and mapping both intact for the retry + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where id = ?" object-id])))) + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))))) + +(t/deftest upload-session-mapping-blocks-gc-deleted + ;; A live upload-session chunk mapping references its object with a NO + ;; ACTION foreign key: gc-deleted cannot purge that object while the + ;; session is intact. The blocked chunk fails and is skipped, and both + ;; the object row and the mapping stay in place, so the stalled session + ;; stays retryable; objects-gc owns purging the mapping before the + ;; object (see upload-session-consumed-purge below). + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + _ (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?" + session-id]))] + (t/is (nil? (:error out))) + (th/db-exec! ["update storage_object set deleted_at = ? where id = ?" (ct/in-past {:minutes 5}) object-id]) + + (th/run-task! :storage-gc-deleted {}) + + ;; the object row survives the run: the mapping blocks its deletion + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where id = ?" + object-id])))) + ;; mapping kept: the session is intact for the retry + (t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?" + session-id])))))) + (t/deftest upload-session-consumed-purge ;; An assembled session is marked as consumed and objects-gc purges it ;; right away, without waiting for the stalled threshold. @@ -1194,11 +1339,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) @@ -1239,11 +1384,11 @@ ::rpc/profile-id (:id prof) :total-chunks 1}) :result :session-id) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index 0 - :content mfile})] + out (th/multipart-command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] (t/is (nil? (:error out))) ;; soft-delete the profile; the live session is neither consumed nor stalled diff --git a/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj b/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj index 54a6372163..4d6277a0ff 100644 --- a/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj +++ b/backend/test/backend_tests/tasks/binfile_jobs_integration_test.clj @@ -67,14 +67,14 @@ ::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)}})] + out (th/multipart-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)) 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/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 79f29f335b..2bbdd7c371 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.consumer-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 consumers (from the consumer constants, not + literals): the delete paths must face pending-ful rows." + [row-id] + (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 consumer]))) + +(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 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")] + (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))))) 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"))))))) diff --git a/backend/test/e2e/import-binfile-path-traversal.test.mjs b/backend/test/e2e/import-binfile-path-traversal.test.mjs new file mode 100644 index 0000000000..df5598aa2f --- /dev/null +++ b/backend/test/e2e/import-binfile-path-traversal.test.mjs @@ -0,0 +1,110 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +function errorCode(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorCode(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.code ?? body["~:code"]; +} + +function errorType(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorType(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.type ?? body["~:type"]; +} + +describe("import-binfile path traversal", () => { + let profile; + let cookie; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + }); + + it("rejects a hand-forged :file path in a transit body", async () => { + // ATTACK SCENARIO: + // 1. The attacker has a valid session and a valid project-id. + // 2. Instead of a real multipart upload, they hand-write a transit + // body (plain JSON, no transit library needed) with a forged + // :file map that points to an internal server file. + // The transit tag ["~#path", "/etc/passwd"] becomes a real + // java.nio.file.Path on the server (see app.common.transit + // read handlers) and passes media.v/schema:upload, which only + // checks the shape, not where the path came from. + // 3. import-binfile then uses (:path file) directly as input-path + // (parse-file-format / get-manifest / import-files!). + // + // EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation, + // without touching the file. While vulnerable, the request passes + // validation and fails later (zip/manifest error) or hangs in SSE, + // proving the file was opened. + const transitBody = JSON.stringify({ + "~:project-id": `~u${profile.defaultProjectId}`, + "~:name": "pwned", + "~:version": 3, + "~:file": { + "~:filename": "evil.zip", + "~:size": 123, + "~:path": ["~#path", "/etc/passwd"], + }, + }); + + const res = await rpcPost("import-binfile", transitBody, { + cookieToken: cookie, + contentType: "application/transit+json", + accept: "application/json", + }); + + assert.equal( + res.status, + 400, + `forged :file path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorType(res.body), + "validation", + `expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorCode(res.body), + "params-validation", + `forged :file path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + }); + + it("plain JSON string path stays rejected", async () => { + // Control case: without the transit "~#path" tag the path arrives + // as a plain string and ::fs/path already rejects it. This test + // documents that only the transit vector bypasses the check. + const res = await rpcPost( + "import-binfile", + { + projectId: profile.defaultProjectId, + name: "pwned", + version: 3, + file: { filename: "evil.zip", size: 123, path: "/etc/passwd" }, + }, + { cookieToken: cookie } + ); + + assert.equal(res.status, 400); + assert.equal(errorType(res.body), "validation"); + assert.equal(errorCode(res.body), "params-validation"); + }); +}); diff --git a/backend/test/e2e/upload-file-media-path-traversal.test.mjs b/backend/test/e2e/upload-file-media-path-traversal.test.mjs new file mode 100644 index 0000000000..7b98f820d2 --- /dev/null +++ b/backend/test/e2e/upload-file-media-path-traversal.test.mjs @@ -0,0 +1,189 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +function errorCode(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorCode(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.code ?? body["~:code"]; +} + +function errorType(body) { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return errorType(JSON.parse(body)); + } catch { + return undefined; + } + } + return body.type ?? body["~:type"]; +} + +async function createFile(cookie, projectId, name = "E2E Media Test File") { + const res = await rpcPost( + "create-file", + { name, projectId }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`); + return res.body; +} + +async function createUploadSession(cookie) { + const res = await rpcPost( + "create-upload-session", + { totalChunks: 1 }, + { cookieToken: cookie } + ); + assert.equal( + res.status, + 200, + `create-upload-session failed: ${JSON.stringify(res.body)}` + ); + return res.body.sessionId ?? res.body["~:session-id"]; +} + +describe("upload-file-media path traversal", () => { + let profile; + let cookie; + let fileId; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + const file = await createFile(cookie, profile.defaultProjectId); + fileId = file.id; + }); + + it("rejects a hand-forged :content path in a transit body", async () => { + // ATTACK SCENARIO: + // 1. The attacker has a valid session and a file they can edit. + // 2. Instead of a real multipart upload, they hand-write a transit + // body (plain JSON, no transit library needed) with a forged + // :content map that points to an internal server file. + // The transit tag ["~#path", "/etc/passwd"] becomes a real + // java.nio.file.Path on the server (see app.common.transit + // read handlers) and passes media.v/schema:upload, which only + // checks the shape, not where the path came from. The attacker + // also controls :size and :mtype, so validate-media-type! and + // validate-media-size! pass with "image/png" and a small size. + // 3. upload-file-media-object then reads (:path content) in + // process-image (media/run :info) and stores the bytes as a + // media object, which is later downloadable by URL. + // + // EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation, + // without touching the file. While vulnerable, the request passes + // validation and fails later in media processing, proving the + // file was opened. + const transitBody = JSON.stringify({ + "~:file-id": `~u${fileId}`, + "~:is-local": true, + "~:name": "pwned", + "~:content": { + "~:filename": "evil.png", + "~:size": 123, + "~:mtype": "image/png", + "~:path": ["~#path", "/etc/passwd"], + }, + }); + + const res = await rpcPost("upload-file-media-object", transitBody, { + cookieToken: cookie, + contentType: "application/transit+json", + accept: "application/json", + }); + + assert.equal( + res.status, + 400, + `forged :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorType(res.body), + "validation", + `expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorCode(res.body), + "params-validation", + `forged :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + }); + + it("rejects a hand-forged chunk :content path in a transit body", async () => { + // Same vector through upload-chunk: the forged path is read with + // sto/content and stored as a chunk blob. While vulnerable this + // returns 200 (the internal file bytes are stored under the + // attacker session, retrievable via assemble) — proving the file + // was read and kept. After the fix it must be 400 + // validation/params-validation. + const sessionId = await createUploadSession(cookie); + + const transitBody = JSON.stringify({ + "~:session-id": `~u${sessionId}`, + "~:index": 0, + "~:content": { + "~:filename": "c", + "~:size": 123, + "~:mtype": "application/octet-stream", + "~:path": ["~#path", "/etc/passwd"], + }, + }); + + const res = await rpcPost("upload-chunk", transitBody, { + cookieToken: cookie, + contentType: "application/transit+json", + accept: "application/json", + }); + + assert.equal( + res.status, + 400, + `forged chunk :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorType(res.body), + "validation", + `expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + assert.equal( + errorCode(res.body), + "params-validation", + `forged chunk :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}` + ); + }); + + it("plain JSON string path stays rejected", async () => { + // Control case: without the transit "~#path" tag the path arrives + // as a plain string and ::fs/path already rejects it. This test + // documents that only the transit vector bypasses the check. + const res = await rpcPost( + "upload-file-media-object", + { + fileId, + isLocal: true, + name: "pwned", + content: { + filename: "evil.png", + size: 123, + mtype: "image/png", + path: "/etc/passwd", + }, + }, + { cookieToken: cookie } + ); + + assert.equal(res.status, 400); + assert.equal(errorType(res.body), "validation"); + assert.equal(errorCode(res.body), "params-validation"); + }); +}); diff --git a/common/package.json b/common/package.json index faef4c3980..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 df3f0eecaf..182b225f53 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/src/app/common/data.cljc b/common/src/app/common/data.cljc index 8a1efbe5d3..9a4aaa3020 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1202,12 +1202,20 @@ (str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c))) "")) +;; \p{L} (Unicode letter) works in Java regex natively, but in JavaScript it +;; requires the "u" flag which ClojureScript regex literals don't support. +;; Do not use the "g" flag: a shared global RegExp mutates lastIndex and +;; corrupts later get-initials calls (org avatar lists in the team switcher). +(def ^:private letter-re + #?(:clj #"\p{L}" + :cljs (js/RegExp. "\\p{L}" "u"))) + (defn get-initials "Returns up to two uppercase initials extracted from a string. - Non-letter prefixes in each token are ignored." + Non-letter prefixes in each token are ignored (Unicode letters)." [name] (->> (str/split (str/trim (or name "")) #"\s+") - (keep #(first (re-seq #"[a-zA-Z]" %))) + (keep #(re-find letter-re %)) (take 2) (map str/upper) (apply str))) diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 5a8a9acaec..f27d1d531d 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -29,6 +29,22 @@ (def ^:dynamic *data-length* 8) (def ^:dynamic *data-level* 8) +(def max-message-length + "Upper bound, in characters, for an exception message surfaced as a hint or + embedded in a formatted stack trace. A message can carry a whole payload (a + serialized file, for instance) and grow to megabytes; only the head helps to + identify the failure, and the rest is noise that bloats reports and the audit + events built from them." + 1000) + +(defn- truncate + "Truncates a string to at most `n` characters, appending an ellipsis when it + had to cut. Values that are not strings pass through unchanged." + [s n] + (if (and (string? s) (> (count s) n)) + (str (subs s 0 n) "...") + s)) + (defmacro error [& {:keys [type hint] :as params}] `(ex-info ~(or hint (name type)) @@ -243,7 +259,7 @@ :cljs (defn format-throwable - [cause & {:as opts}] + [cause & {:keys [trace-length]}] (with-out-str (when-let [exdata (ex-data cause)] (when-let [hint (or (get exdata :hint) @@ -251,7 +267,7 @@ (when (str/index-of hint "\n") (println "Hint:") (println "--------------------") - (println hint) + (println (truncate hint max-message-length)) (println))) (when-let [explain (get exdata ::sm/explain)] @@ -272,14 +288,20 @@ (println)) (when-let [trace (.-stack cause)] - (println "Trace:") - (println "--------------------") - (println (.-stack cause)))))) + (let [lines (str/lines trace) + head (truncate (first lines) max-message-length) + tail (rest lines)] + (println "Trace:") + (println "--------------------") + (when head + (println head)) + (doseq [line (if trace-length (take trace-length tail) tail)] + (println line))))))) (defn first-line [s] (let [break-index (str/index-of s "\n")] - (if (pos? break-index) + (if (and (some? break-index) (pos? break-index)) (subs s 0 break-index) s))) @@ -308,5 +330,6 @@ (defn get-hint [cause] - (or (some-> (ex-data cause) (get :hint) first-line) - (some-> (ex-message cause) first-line))) + (truncate (or (some-> (ex-data cause) (get :hint) first-line) + (some-> (ex-message cause) first-line)) + max-message-length)) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 49d93407a0..2e02166a93 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -16,6 +16,7 @@ [app.common.files.helpers :as cph] [app.common.files.migrations :as fmig] [app.common.geom.shapes :as gsh] + [app.common.path-names :as cpn] [app.common.schema :as sm] [app.common.svg :as csvg] [app.common.time :as dt] @@ -102,7 +103,8 @@ (str/capital (d/name type))))) :always - (update :name unique-name state))) + (-> (update :name cpn/clean-path) + (update :name unique-name state)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMAS diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 6692c36528..72c5dcac18 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -21,21 +21,24 @@ [app.common.geom.shapes.text :as gsht] [app.common.logging :as l] [app.common.math :as mth] + [app.common.path-names :as cpn] [app.common.schema :as sm] [app.common.svg :as csvg] [app.common.types.color :as types.color] [app.common.types.component :as ctk] + [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.fills :as types.fills] [app.common.types.path :as path] [app.common.types.path.segment :as path.segment] [app.common.types.shape :as cts] + [app.common.types.shape-tree :as ctt] [app.common.types.shape.interactions :as ctsi] [app.common.types.shape.shadow :as ctss] [app.common.types.shape.text :as ctst] [app.common.types.text :as types.text] - [app.common.types.token :as ctt] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.variant :as ctv] [app.common.uuid :as uuid] @@ -300,9 +303,14 @@ [data _] (letfn [(fix-line-paths [shape] (if (= (:type shape) :path) - (let [{:keys [width height]} (grc/points->rect (:points shape))] - (if (or (mth/almost-zero? width) (mth/almost-zero? height)) - (let [selrect (path.segment/content->selrect (:content shape)) + ;; A path with missing or empty points has no rect; rebuild + ;; its geometry from the content as for line-like paths. + (let [{:keys [width height] :as rect} (grc/points->rect (:points shape))] + (if (or (nil? rect) (mth/almost-zero? width) (mth/almost-zero? height)) + ;; Content whose points are not numbers gives no rect + ;; either; fall back to the empty rect. + (let [selrect (or (path.segment/content->selrect (:content shape)) + (grc/make-rect)) points (grc/rect->points selrect) transform (gmt/matrix) transform-inv (gmt/matrix)] @@ -1411,6 +1419,16 @@ (let [decode-segments (sm/decoder path/schema:segments sm/json-transformer) + ;; Old files may have segments with missing coordinates, which + ;; the binary path data can not store; drop them. + valid-segment? + (fn [{:keys [command params]}] + (case command + :close-path true + (:move-to :line-to) (and (number? (:x params)) (number? (:y params))) + :curve-to (every? #(number? (get params %)) [:x :y :c1x :c1y :c2x :c2y]) + false)) + update-object (fn [object] (if (or (cfh/bool-shape? object) @@ -1424,9 +1442,10 @@ (path/content []) :else - (-> content - (decode-segments) - (path/content)))] + (->> content + (decode-segments) + (filterv valid-segment?) + (path/content)))] (assoc object :content content)) object)) @@ -2244,7 +2263,7 @@ (if (some? token) (update shape :applied-tokens (fn [applied] - (-> (zipmap ctt/per-side-stroke-width-keys (repeat token)) + (-> (zipmap cto/per-side-stroke-width-keys (repeat token)) (merge applied) (dissoc :stroke-width)))) shape))) @@ -2294,6 +2313,66 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0033-normalize-main-instance-names" + [data _] + ;; In penpot shape names always have "/" characters normalized with one space between and after, + ;; to form paths. But this is not enforced and may be places that still admit names like + ;; "Group/Name" or "Group / Name". With normal shapes this is not a problem. But for main + ;; instances, the name of the root shape must match exactly the path+name of the component. + ;; Here we normalize the names of all main instances, and a new file integrity validation has + ;; been added to enforce this from now on. + ;; Main instances in variants are a special case because they need to match the variant name. + (letfn [(update-shape [container shape-id] + (let [shape (ctn/get-shape container shape-id) + + shape (cond + ;; Variant container must have a normalized name + (ctk/is-variant-container? shape) + (let [name (:name shape) + clean-name (cpn/clean-path name) + clean-name (if (str/blank? clean-name) + "(empty)" ;; this may occur if the name contains only separators + clean-name)] + (if (= name clean-name) + shape + (assoc shape :name clean-name))) + + ;; Main instance inside a variant must have the same name as the container + (ctk/main-instance? shape) + (if (ctk/is-variant? shape) + (let [variant-container (ctn/get-shape container (:parent-id shape))] + (if (or (= (:name shape) (:name variant-container)) + (nil? variant-container)) + shape + (assoc shape :name (:name variant-container)))) + + ;; Other main instances must have the same name as the path+name of the component + (let [component (ctkl/get-component data (:component-id shape) true) + expected-name (when component + (cpn/merge-path-item (:path component) (:name component)))] + (if (or (= expected-name (:name shape)) + (nil? expected-name)) + shape + (assoc shape :name expected-name)))) + + ;; Any other shape does not need processing + :else shape)] + + (reduce update-shape + (ctt/set-shape container shape) + (:shapes shape)))) + + (update-container + [container] + (let [root (ctn/get-container-root container)] + (if root + (update-shape container (:id root)) + container)))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -2383,4 +2462,5 @@ "0029-move-background-blur-out-of-blur" "0030-remove-stroke-per-side-attr" "0031-migrate-stroke-width-token-attr" - "0032-fix-children-not-in-parent"])) + "0032-fix-children-not-in-parent" + "0033-normalize-main-instance-names"])) diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index a8d98a2d87..4756f8a758 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -7,6 +7,7 @@ (ns app.common.files.repair (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -215,6 +216,20 @@ (pcb/with-library-data file-data) (pcb/update-component (:component-id shape) repair-component)))) +(defmethod repair-error :invalid-main-instance-name + [_ {:keys [shape page-id args] :as error} file-data _] + (let [repair-shape + (fn [shape] + ;; Change the name to match the component + (let [component-name (:component-name args)] + (log/debug :hint (str " -> rename shape " (:id shape) " to " component-name)) + (assoc shape :name component-name)))] + + (log/debug :hint "repairing shape :invalid-main-instance-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) + (defmethod repair-error :invalid-main-instance [_ {:keys [shape page-id] :as error} file-data _] (let [repair-shape @@ -729,6 +744,42 @@ (pcb/with-file-data file-data) (pcb/update-shapes [parent-id] repair-fn)))) +(defmethod repair-error :variant-container-bad-name + [_ {:keys [shape page-id args]} file-data _] + (let [clean-name (:clean-name args) + + repair-shape + (fn [shape] + (log/debug :hint " -> set :name of shape" :id (:id shape) :name clean-name) + (assoc shape :name clean-name)) + + repair-component + (fn [component] + (let [[path name] (cpn/split-group-name clean-name)] + (log/debug :hint " -> set :path and :name of component" :id (:id component) :path path :name name) + (assoc component :path path :name name))) + + all-shape-ids + (cons (:id shape) (:shapes shape)) + + objects + (dm/get-in file-data [:pages-index page-id :objects]) + + all-main-instances + (map (d/getf objects) (:shapes shape)) + + all-component-ids + (map :component-id all-main-instances)] + + (log/debug :hint "repairing shape :variant-container-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (as-> (pcb/empty-changes nil page-id) $ + (pcb/with-file-data $ file-data) + (pcb/with-library-data $ file-data) + (pcb/update-shapes $ all-shape-ids repair-shape) + (reduce #(pcb/update-component %1 %2 repair-component) + $ + all-component-ids)))) + (defmethod repair-error :variant-main-bad-name [_ {:keys [shape page-id args]} file-data _] (let [repair-fn @@ -771,14 +822,22 @@ (fn [shape] (let [variant-id (:variant-id args)] ;; Set the desired variant-id - (log/debug :hint (str " -> set variant-id to " variant-id)) - (assoc shape - :variant-id variant-id)))] + (log/debug :hint (str " -> set variant-id in shape to " variant-id)) + (assoc shape :variant-id variant-id))) + + repair-component + (fn [component] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id in component to " variant-id)) + (assoc component :variant-id variant-id)))] (log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) - (pcb/update-shapes [(:id shape)] repair-shape)))) + (pcb/with-library-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape) + (pcb/update-component (:component-id shape) repair-component)))) (defmethod repair-error :missing-tokens-status [_ _ file-data _] diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 34c5b760d6..e8a28e7ca2 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -523,7 +523,9 @@ (effective-tokens-source? file-data (:id file-data)))) (defn editable-tokens? - "Returns true if the file-data is its own tokens source." + "Returns true if the file-data is its own tokens source. + Call only with the current local file's data. This predicate does not check + which file is open; callers must reject connected libraries first." [file-data] (effective-tokens-source? file-data (:id file-data))) @@ -547,6 +549,32 @@ [file-data f & args] (d/update-when file-data :tokens-status #(apply f % args))) +(defn absorb-tokens-lib + "Copy the tokens-lib of the library into the file, replacing the + previous one if any, and unset the tokens-source, so the file becomes + its own tokens source. + + The tokens-status of the file is preserved. If the file has none, the + one of the library is copied along with the tokens-lib. + + If the library has no tokens-lib, only the tokens-source is unset, and + the file keeps its own tokens-lib and tokens-status. If there is no + library-data, there is nothing to absorb and the file is returned + unchanged." + [file-data library-data] + (if (nil? library-data) + file-data + (let [tokens-lib (get-tokens-lib library-data)] + (if (nil? tokens-lib) + (set-tokens-source file-data nil) + (let [tokens-status (or (get-tokens-status file-data) + (get-tokens-status library-data))] + (cond-> (-> file-data + (set-tokens-source nil) + (assoc :tokens-lib tokens-lib)) + (some? tokens-status) + (assoc :tokens-status tokens-status))))))) + ;; Tokens status with tokens lib (defn- calculate-active-sets @@ -784,4 +812,4 @@ {:active-theme-ids active-theme-ids :active-set-ids active-set-ids}) {:active-theme-ids #{} - :active-set-ids #{}})) \ No newline at end of file + :active-set-ids #{}})) diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 9def402691..40bf2d4af2 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -41,6 +41,7 @@ :duplicate-slot :invalid-main-instance-id :invalid-main-instance-page + :invalid-main-instance-name :invalid-main-instance :invalid-parent :component-main @@ -70,6 +71,7 @@ :invalid-variant-properties :variant-not-main :parent-not-variant + :variant-container-bad-name :variant-main-bad-name :variant-main-bad-variant-name :variant-component-bad-name @@ -302,7 +304,14 @@ shape file page) (report-error :invalid-main-instance-page (str/ffmt "Main instance page of component % is not valid" (:component-id shape)) - shape file page)))))))) + shape file page)))) + (when-not (ctk/is-variant? component) ;; Variants are checked elsewhere + (let [component-name (cpn/merge-path-item (:path component) (:name component))] + (when-not (= (:name shape) component-name) + (report-error :invalid-main-instance-name + (str/ffmt "Main instance % has an invalid name" (:id shape)) + shape file page + :component-name component-name)))))))) (defn- check-component-not-main-head "Validate shape is a not-main instance head, component @@ -570,14 +579,25 @@ (defn- check-variant-container "Shape is a variant container, so: -all its children should be variants with variant-id equals to the shape-id - -all the components should have the same properties" + -all the components should have the same properties + -the name of the container should be normalized for paths (separator / with one space at each side)." [shape file page] - (let [shape-id (:id shape) - shapes (:shapes shape) - objects (:objects page) - file-data (:data file) - first-child (get objects (first shapes)) - prop-names (cfv/extract-properties-names first-child file-data)] + (let [shape-id (:id shape) + container-name (:name shape) + shapes (:shapes shape) + objects (:objects page) + file-data (:data file) + first-child (get objects (first shapes)) + prop-names (cfv/extract-properties-names first-child file-data) + clean-path (cpn/clean-path container-name) + clean-path (if (str/blank? clean-path) + "(empty)" ;; this may occur if the name contains only separators + clean-path)] + (when-not (= container-name clean-path) + (report-error :variant-container-bad-name + (str/ffmt "Variant container % has a bad name %" shape-id container-name) + shape file page + :clean-name clean-path)) (run! (fn [child-id] (when-let [child (get objects child-id)] (if (not (ctk/is-variant? child)) @@ -598,6 +618,11 @@ :prop-names prop-names)))))) shapes))) +(defn- renamed-container? + [container] + (and (some? container) + (not= (:name container) (cpn/clean-path (:name container))))) + (defn- check-variant "Shape is a variant, so -it should be a main component @@ -622,21 +647,23 @@ (str/ffmt "Variant % has an invalid variant-name" (:id shape)) shape file page :variant-name variant-name)) - (when-not (= (:name parent) (:name shape)) - (report-error :variant-main-bad-name - (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) - shape file page - :variant-name (:name parent))) - (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) - (report-error :variant-component-bad-name - (str/ffmt "Component % has an invalid name" (:id shape)) - shape file page - :variant-container-name (:name parent))) + ;; If the container is going to be renamed all main and components will be renamed, too + (when-not (renamed-container? parent) + (when-not (= (:name parent) (:name shape)) + (report-error :variant-main-bad-name + (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) + shape file page + :variant-name (:name parent))) + (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) + (report-error :variant-component-bad-name + (str/ffmt "Component % has an invalid name" (:id shape)) + shape file page + :variant-container-name (:name parent)))) (when-not (= (:variant-id component) (:variant-id shape)) (report-error :variant-component-bad-id - (str/ffmt "Variant % has adifferent variant-id than its component" (:id shape)) + (str/ffmt "Variant % has a :variant-id that does not point to the container" (:id shape)) shape file page - :variant-id (:variant-id component))))) + :variant-id (:parent-id shape))))) (defn- check-shape "Validate referential integrity and semantic coherence of diff --git a/common/src/app/common/fonts.cljs b/common/src/app/common/fonts.cljs index 237a6b362b..ee96b6c9df 100644 --- a/common/src/app/common/fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -213,7 +213,12 @@ ;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF), ;; Legacy Computing Symbols (U+1FB00-1FBFF) :symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]" - :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"}) + :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]" + ;; Arrows, Mathematical Operators, Misc Mathematical Symbols A/B, Supplemental + ;; Arrows A/B, Supplemental Math Operators: the Symbols fonts lack most of them + :math #"[\u2190-\u21FF\u2200-\u22FF\u27C0-\u27FF\u2900-\u2AFF]" + ;; Letterlike Symbols (ℝ, ℕ, ℃, ™, ...) + :letterlike #"[\u2100-\u214F]"}) (defn contains-emoji? [text] (let [result (re-find emoji-pattern text)] @@ -292,7 +297,9 @@ :meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} - :music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}}) + :music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} + :math {:font-id "gfont-noto-sans-math" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} + :letterlike {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}}) (defn add-noto-fonts [fonts languages] (reduce (fn [acc lang] diff --git a/common/src/app/common/geom/image_crop.cljc b/common/src/app/common/geom/image_crop.cljc new file mode 100644 index 0000000000..15ad24b9bd --- /dev/null +++ b/common/src/app/common/geom/image_crop.cljc @@ -0,0 +1,54 @@ +;; 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.geom.image-crop + "Geometry of the image crop (resize with the mod key on a shape with an + image fill). The shape is a window over a picture that keeps its size + and position; the window never flips." + (:require + [app.common.geom.matrix :as gmt] + [app.common.math :as mth])) + +(defn crop-axis + "Resize one axis of the crop window. `size` is the current window size, + `origin` the fixed point relative to the window start and `scale` the + resize factor, negative when the dragged edge crosses the fixed point. + Returns `[offset size]`: the new window start relative to the old one, + and the new size, always positive." + [size origin scale] + (let [start (* origin (- 1 scale)) + end (+ origin (* (- size origin) scale))] + [(mth/min start end) (mth/abs (- end start))])) + +(defn update-image-transform + "Return the image fill transform that keeps the picture in place when + the window of `width` x `height` moves by `dx`, `dy` and gets a size of + `width'` x `height'`. The transform values are relative to the window + size, and a nil transform means the picture fills the window." + [transform width height dx dy width' height'] + (let [x (get transform :x 0.0) + y (get transform :y 0.0) + w (get transform :width 1.0) + h (get transform :height 1.0)] + {:x (/ (- (* x width) dx) width') + :y (/ (- (* y height) dy) height') + :width (/ (* w width) width') + :height (/ (* h height) height')})) + +(defn preview-image-matrix + "Return the matrix that takes the picture of a `width` x `height` window + from its box under `transform` to its box under `transform'`, both in + the coordinates of the window before the resize. While dragging, the + SVG preview resizes the whole shape with one matrix; this one, applied + to the picture first, undoes that resize so the picture stays in place." + [transform transform' width height] + (let [x (* (get transform :x 0.0) width) + y (* (get transform :y 0.0) height) + x' (* (get transform' :x 0.0) width) + y' (* (get transform' :y 0.0) height) + sx (/ (get transform' :width 1.0) (get transform :width 1.0)) + sy (/ (get transform' :height 1.0) (get transform :height 1.0))] + (gmt/matrix sx 0 0 sy (- x' (* x sx)) (- y' (* y sy))))) 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/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index b09145ec1c..53d2ae5396 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -15,15 +15,34 @@ ;; Tokens source +(defn- own-tokens-status + "The current tokens status of `file-data`, keeping only the themes and sets + that exist in its own tokens lib." + [file-data] + (let [tokens-status (cfo/get-tokens-status file-data) + tokens-lib (cfo/get-tokens-lib file-data)] + (if (and tokens-status tokens-lib) + (cfo/sync-tokens-status-with-lib tokens-status tokens-lib) + (ctos/make-tokens-status)))) + (defn generate-set-tokens-source "Create changes for setting the tokens source of a file to `library`, - copying the library's tokens status. If the library is nil, the tokens source - is removed and the tokens status is cleared." + copying the library's tokens status. If the library is the file itself, + the themes and sets that don't exist in its own tokens lib are deactivated. + If the library is nil, the tokens source is removed and the tokens status + is cleared." [changes library] (let [library-id (:id library) - library-tokens-status (if library - (-> library ctf/file-data cfo/get-tokens-status) - (ctos/make-tokens-status))] + file-data (pcb/get-library-data changes) + library-tokens-status (cond + (nil? library) + (ctos/make-tokens-status) + + (= library-id (:id file-data)) + (own-tokens-status file-data) + + :else + (-> library ctf/file-data cfo/get-tokens-status))] (-> changes (pcb/set-tokens-source library-id) (pcb/set-tokens-status library-tokens-status)))) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index ea0c11b530..a82759a7bd 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -99,26 +99,34 @@ (str/replace filename image-extensions-re ""))) (defn parse-font-weight + "Parses a font variant/subfamily string into a numeric weight. + A nil or non-string variant means the font carries no subfamily + metadata, so it defaults to 400 (regular), the same as a blank string." [variant] - (cond - (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100 - (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200 - (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300 - (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400 - (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500 - (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600 - (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800 - (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700 - (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950 - (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900 - :else 400)) + (let [variant (if (string? variant) variant "")] + (cond + (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200 + (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300 + (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400 + (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500 + (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800 + (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950 + (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900 + :else 400))) (defn parse-font-style + "Parses a font variant/subfamily string into \"italic\" or \"normal\". + A nil or non-string variant means the font carries no subfamily + metadata, so it defaults to \"normal\"." [variant] - (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) - (re-seq #"(?i)italic$" variant)) - "italic" - "normal")) + (let [variant (if (string? variant) variant "")] + (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) + (re-seq #"(?i)italic$" variant)) + "italic" + "normal"))) (defn font-weight->name [weight] diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index de96093155..cc1526eae0 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -118,7 +118,10 @@ Some naming conventions: [path] (last (split-path path))) -(defn inside-path? [child parent] +(defn inside-path? + "Returns true when `child` equals `parent` or is nested inside it, + comparing both as path segments (e.g. 'a / b / c' is inside 'a / b')." + [child parent] (let [child-path (split-path child) parent-path (split-path parent)] (and (<= (count parent-path) (count child-path)) @@ -135,90 +138,94 @@ Some naming conventions: ;; Tree building functions -------------------------------------------------- -"Build tree structure from flat list of paths" - -"`build-tree-root` is the main function to build the tree." - -"Receives a list of segments with 'name' properties representing paths, - and a separator string." -"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]" - -"Transforms into a tree structure like: - [{:name 'one' - :path 'one' - :depth 0 - :leaf nil - :children [{:name 'two' - :path 'one.two' - :depth 1 - :leaf nil - :children [{... :name 'three'} {... :name 'four'}]} - {:name 'five' - :path 'one.five' - :depth 1 - :leaf {... :name 'five'} - :children nil}]}]" - -(defn- sort-by-children - "Sorts segments so that those with children come first." - [segments separator] - (sort-by (fn [segment] - (let [path (split-path (:name segment) :separator separator) - path-length (count path)] - (if (= path-length 1) - 1 - 0))) - segments)) +(defn- attach-remaining-path + "Annotates a segment with ::remaining-path: the vector of path segments + still to descend after the current level. Computed once, at the root, + from the segment :name." + [segment separator] + (assoc segment ::remaining-path (split-path (:name segment) :separator separator))) (defn- group-by-first-segment - "Groups segments by their first path segment and update segment name." - [segments separator] + "Groups segments by the first element of their ::remaining-path and + advances that path by one element. + + A segment whose ::remaining-path becomes empty is a token that ends at + the current node; a non-empty one still has descendants below it." + [segments] (reduce (fn [acc segment] - (let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator) - rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))] - (update acc first-segment (fnil conj []) - (if rest-path - (assoc segment :name rest-path) - segment)))) + (let [path (::remaining-path segment)] + (update acc (first path) (fnil conj []) + (update segment ::remaining-path subvec 1)))) {} segments)) (defn- sort-and-group-segments - "Sorts elements and groups them by their first path segment." - [segments separator] - (let [sorted (sort-by-children segments separator) - grouped (group-by-first-segment sorted separator)] - (into (sorted-map) grouped))) + "Groups elements by their first path segment, keyed in sorted order." + [segments] + (into (sorted-map) (group-by-first-segment segments))) (defn- build-tree-node "Builds a single tree node with computed children." - [segment-name remaining-segments separator parent-path depth] + [segment-name remaining-segments parent-path depth] (let [current-path (if parent-path (str parent-path "." segment-name) segment-name) - is-leaf? (and (seq remaining-segments) - (every? (fn [segment] - (let [remaining-segment-name (first (split-path (:name segment) :separator separator))] - (= segment-name remaining-segment-name))) - remaining-segments)) + leaf-segment (some (fn [segment] + (when (empty? (::remaining-path segment)) + (-> segment + (assoc :name segment-name) + (dissoc ::remaining-path)))) + remaining-segments) - leaf-segment (when is-leaf? (first remaining-segments)) - node {:name segment-name - :path current-path - :depth depth - :leaf leaf-segment - :children (when-not is-leaf? - (let [grouped-elements (sort-and-group-segments remaining-segments separator)] - (mapv (fn [[child-segment-name remaining-child-segments]] - (build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth))) - grouped-elements)))}] - node)) + child-segments (filter #(seq (::remaining-path %)) remaining-segments) + + children (when (seq child-segments) + (let [grouped-elements (sort-and-group-segments child-segments)] + (mapv (fn [[child-segment-name remaining-child-segments]] + (build-tree-node child-segment-name remaining-child-segments current-path (inc depth))) + grouped-elements)))] + + {:name segment-name + :path current-path + :depth depth + :leaf leaf-segment + :children children})) + +(defn- attach-remaining-paths + "Annotates every segment with its ::remaining-path vector." + [segments separator] + (map #(attach-remaining-path % separator) segments)) + +(defn- build-root-node + "Builds a root-level tree node from a grouped element." + [[segment-name remaining-segments]] + (build-tree-node segment-name remaining-segments nil 0)) (defn build-tree-root - "Builds the root level of the tree." + "Builds the root level of the tree from a flat list of path segments. + + Receives a list of segments with a :name that represents a path, and + the separator used to split each name, e.g.: + + [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}] + + Transforms them into a tree of nodes shaped like: + + [{:name 'one' + :path 'one' + :depth 0 + :leaf nil + :children [{:name 'two' + :path 'one.two' + :depth 1 + :leaf nil + :children [{... :name 'three'} {... :name 'four'}]} + {:name 'five' + :path 'one.five' + :depth 1 + :leaf {... :name 'five'} + :children nil}]}]" [segments separator] - (let [grouped-elements (sort-and-group-segments segments separator)] - (mapv (fn [[segment-name remaining-segments]] - (build-tree-node segment-name remaining-segments separator nil 0)) - grouped-elements))) + (let [grouped-elements (sort-and-group-segments (attach-remaining-paths segments separator))] + (mapv build-root-node grouped-elements))) diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index 8bbadf4c40..34f0cc8467 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -62,7 +62,6 @@ (thc/make-component component2-label root2-label) (thc/update-component component2-label {:variant-id variant-id :variant-properties [{:name "Property 1" :value "Value2"}]})))) - (defn add-variant-with-copy [file variant-label component1-label root1-label component2-label root2-label child1-label child2-label component-copy-label] (let [file (ths/add-sample-shape file variant-label :type :frame :is-variant-container true) @@ -77,8 +76,6 @@ (thc/make-component component2-label root2-label) (thc/update-component component2-label {:variant-id variant-id :variant-properties [{:name "Property 1" :value "Value2"}]})))) - - (defn add-variant-with-text [file variant-label component1-label root1-label component2-label root2-label child1-label child2-label text1 text2 & {:keys [text1-params text2-params]}] diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index c4ab29e3da..df4733c25a 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -11,6 +11,7 @@ [app.common.features :as cfeat] [app.common.files.defaults :refer [version]] [app.common.files.helpers :as cfh] + [app.common.files.tokens :as cfo] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.tree-seq :as gsts] @@ -1238,7 +1239,9 @@ (fn [pages-index] (d/update-vals pages-index #(update % :objects detach-objects)))] - (update-in file [:data :pages-index] detach-pages))) + (cond-> (update-in file [:data :pages-index] detach-pages) + (not (cfo/effective-tokens-source? (:data file) file-id)) + (update :data dissoc :tokens-source :tokens-status)))) ;; Base font size @@ -1256,23 +1259,22 @@ (defn get-ref-chain-until-target-ref "Returns a vector with the shape ref chain until target-ref, including itself" [container libraries shape target-ref] - (loop [chain [shape] - current shape] - (if (= current target-ref) + (loop [chain [shape] + current shape + container container] + (if (= (:id current) target-ref) chain (if-let [ref (find-ref-shape nil container libraries current :with-context? true)] - (recur (conj chain ref) ref) + (recur (conj chain ref) ref (:container (meta ref))) chain)))) (defn get-touched-from-ref-chain-until-target-ref "Returns a set with the :touched of all the items on the shape ref chain until target-ref, including itself" - [container libraries shape target-ref] - (let [chain (get-ref-chain-until-target-ref container libraries shape target-ref) - more-touched (->> chain - (map :touched) - (remove nil?) - (apply set/union) - (remove ctk/swap-slot?) - set)] - (set/union (or (:touched shape) #{}) more-touched))) + [container libraries shape target-ref & {:keys [remove-slots?] :or {remove-slots? true}}] + (let [chain (get-ref-chain-until-target-ref container libraries shape target-ref) + more-touched (cond->> chain + :always (map :touched) + :always (reduce set/union #{}) + remove-slots? (remove ctk/swap-slot?))] + (set/union (or (:touched shape) #{}) (set more-touched)))) \ No newline at end of file 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/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/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index a99a9502d2..082da2d43e 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -147,7 +147,9 @@ (check-token-attrs) (map->Token))) -(def ^:private token-separator ".") +(def token-separator + "Separator used to split a token name into its path segments." + ".") (defn get-token-path [token] @@ -167,9 +169,9 @@ (rename-path node nil new-name)) ([node token new-name] (let [element (if token (:name token) (:path node)) - split-path (cpn/split-path element :separator ".") + split-path (cpn/split-path element :separator token-separator) updated-split-element-name (assoc split-path (:depth node) new-name) - new-element-path (cpn/join-path updated-split-element-name :separator "." :with-spaces? false)] + new-element-path (cpn/join-path updated-split-element-name :separator token-separator :with-spaces? false)] new-element-path))) ;; === Token Set diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 353435bab8..fa56367800 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -34,7 +34,18 @@ (t/is (= "AB" (d/get-initials "123 Alpha ## beta"))) (t/is (= "PD" (d/get-initials " penpot design tool "))) (t/is (= "" (d/get-initials nil))) - (t/is (= "" (d/get-initials "!!! ???")))) + (t/is (= "" (d/get-initials "!!! ???"))) + ;; Accented / Unicode letters (org default avatars) + (t/is (= "Á" (d/get-initials "Ábaco"))) + (t/is (= "Ó" (d/get-initials "Órbita"))) + (t/is (= "ÁN" (d/get-initials "Álvaro Núñez"))) + ;; Sequential calls must stay stable (CLJS global RegExp lastIndex) + (t/is (= ["Á" "BB" "JG" "L" "Á"] + (mapv d/get-initials ["Ábaco" + "Black Bird" + "Jean Grey Academy" + "Library" + "Ábaco"])))) (t/deftest normalize-string-test ;; nil input returns empty string diff --git a/common/test/common_tests/exceptions_test.cljc b/common/test/common_tests/exceptions_test.cljc new file mode 100644 index 0000000000..6f9df8da60 --- /dev/null +++ b/common/test/common_tests/exceptions_test.cljc @@ -0,0 +1,57 @@ +;; 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 common-tests.exceptions-test + "Exception message bounding in `app.common.exceptions`. + + A message can carry a whole payload (a serialized file, for instance) and + grow to megabytes. `get-hint` and the CLJS `format-throwable` keep a bounded + head so neither the audit `:hint` prop nor the report can explode." + (:require + #?(:cljs [cuerdas.core :as str]) + [app.common.exceptions :as ex] + [clojure.test :as t])) + +(defn- huge-string + [n] + (apply str (repeat n "x"))) + +(t/deftest first-line-keeps-a-single-line-message-intact-test + (t/is (= "boom" (ex/first-line "boom"))) + (t/is (= "boom" (ex/first-line "boom\nrest of the message")))) + +(t/deftest get-hint-returns-the-first-line-test + (t/is (= "boom" (ex/get-hint (ex-info "boom\nsecond line" {}))))) + +(t/deftest get-hint-prefers-the-ex-data-hint-test + (t/is (= "from-data" (ex/get-hint (ex-info "from-message" {:hint "from-data"}))))) + +(t/deftest get-hint-bounds-a-huge-message-test + (let [huge (huge-string (* 4 ex/max-message-length)) + hint (ex/get-hint (ex-info huge {}))] + (t/is (string? hint)) + (t/is (<= (count hint) (+ ex/max-message-length 3))) + (t/is (= (subs hint 0 32) (subs huge 0 32))))) + +(t/deftest get-hint-bounds-a-huge-ex-data-hint-test + (let [huge (huge-string (* 4 ex/max-message-length)) + hint (ex/get-hint (ex-info "short" {:hint huge}))] + (t/is (<= (count hint) (+ ex/max-message-length 3))) + (t/is (= (subs hint 0 32) (subs huge 0 32))))) + +#?(:cljs + (t/deftest format-throwable-bounds-a-huge-trace-message-test + (let [huge (huge-string (* 4 ex/max-message-length)) + out (ex/format-throwable (ex-info huge {})) + msg (some #(when (str/includes? % (subs huge 0 16)) %) (str/lines out))] + (t/is (some? msg)) + (t/is (<= (count msg) (+ ex/max-message-length 16)))))) + +#?(:cljs + (t/deftest format-throwable-honors-trace-length-test + (let [full (ex/format-throwable (ex-info "boom" {})) + short (ex/format-throwable (ex-info "boom" {}) {:trace-length 1})] + (t/is (< (count (str/lines short)) (count (str/lines full))))))) diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc index d5aba47c98..27f6964de2 100644 --- a/common/test/common_tests/files/repair_test.cljc +++ b/common/test/common_tests/files/repair_test.cljc @@ -15,6 +15,7 @@ [app.common.files.repair :as cfr] [app.common.files.validate :as cfv] [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] @@ -24,6 +25,48 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest repair-invalid-main-instance-name + (t/testing "detect and repair a main instance whose name doesn't match the component's name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (tho/add-simple-component-with-copy :component1 :root1 :child1 :copy1) + (tho/add-simple-component-with-copy :component2 :root2 :child2 :copy2) + + ;; Update component paths and names + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component1"}) + (thc/update-component :component2 {:path "Group / Subgroup" :name "Component2"}) + + ;; Update component1 shapes name to have path structure + (ths/update-shape :root1 :name "Group / Subgroup / Component1") + (ths/update-shape :copy1 :name "Group / Subgroup / Component1") + + ;; Update component2's name to have unnormalized path + ;; Note that the copy name is allowed to be different + (ths/update-shape :root2 :name "Group/Subgroup / Component2") + (ths/update-shape :copy2 :name "Group/Subgroup / Component2")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + root1' (ths/get-shape file' :root1) + child1' (ths/get-shape file' :child1) + copy1' (ths/get-shape file' :copy1) + root2' (ths/get-shape file' :root2) + child2' (ths/get-shape file' :child2) + copy2' (ths/get-shape file' :copy2)] + + (t/is (= 1 (count errors))) + (t/is (= :invalid-main-instance-name (:code (first errors)))) + (t/is (nil? errors')) + + (t/is (= "Group / Subgroup / Component1" (:name root1'))) + (t/is (= "Rect1" (:name child1'))) + (t/is (= "Group / Subgroup / Component1" (:name copy1'))) + + (t/is (= "Group / Subgroup / Component2" (:name root2'))) + (t/is (= "Rect1" (:name child2'))) + (t/is (= "Group/Subgroup / Component2" (:name copy2')))))) + (t/deftest repair-main-instance-not-a-variant (t/testing "detect and repair a variant component whose root shape is not a variant" (let [file (-> (thf/sample-file :file1 :page-label :page1) @@ -44,7 +87,7 @@ (t/is (= (thi/id :variant1) (:variant-id root1')))))) (t/deftest repair-invalid-variant-id-variant-component-bad-id - (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (t/testing "detect and repair a variant main whose variant id does not match the container's id" (let [file (-> (thf/sample-file :file1 :page-label :page1) (thv/add-variant :variant1 :component1 :root1 :component2 :root2) (ths/update-shape :root1 :variant-id (uuid/next))) @@ -61,7 +104,25 @@ (t/is (= :variant-component-bad-id (:code (second errors)))) (t/is (nil? errors')) - (t/is (= (thi/id :variant1) (:variant-id root1')))))) + (t/is (= (thi/id :variant1) (:variant-id root1'))))) + + (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (thc/update-component :component1 {:variant-id (uuid/next)})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + component1' (thc/get-component file' :component1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-component-bad-id (:code (first errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id component1')))))) (t/deftest repair-invalid-variant-properties (t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties" @@ -161,23 +222,60 @@ (t/is (true? (:is-variant-container container')))))) +(t/deftest repair-variant-container-bad-name + (t/testing "detect and repair a variant container who has an unnormalized name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Variant container has a name that is not normalized and thus it cannot + ;; match the component names, that are separated in path and group. + (ths/update-shape :variant1 :name "Group / Subgroup/Component") + (ths/update-shape :root1 :name "Group / Subgroup/Component") + (ths/update-shape :root2 :name "Group / Subgroup/Component") + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thc/update-component :component2 {:path "Group/Subgroup" :name "Component"})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + var1' (ths/get-shape file' :variant1) + root1' (ths/get-shape file' :root1) + comp1' (thc/get-component file' :component1) + root2' (ths/get-shape file' :root2) + comp2' (thc/get-component file' :component2)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-container-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Group / Subgroup / Component" (:name var1'))) + (t/is (= "Group / Subgroup / Component" (:name root1'))) + (t/is (= "Group / Subgroup" (:path comp1'))) + (t/is (= "Component" (:name comp1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path comp2'))) + (t/is (= "Component" (:name comp2')))))) + (t/deftest repair-variant-main-bad-name (t/testing "detect and repair a main instance whose name doesn't match the variant container's name" (let [file (-> (thf/sample-file :file1 :page-label :page1) (thv/add-variant :variant1 :component1 :root1 :component2 :root2) - ;; Change root1's name so it doesn't match the container + ;; Change component's name so it doesn't match the container + (thc/update-component :component1 :name "WrongName") (ths/update-shape :root1 :name "WrongName")) errors (cfv/validate-file file {}) changes (cfr/repair-file file {} errors) file' (thf/apply-changes file {:redo-changes changes} :validate? false) errors' (cfv/validate-file file' {}) - root1' (ths/get-shape file' :root1)] + root1' (ths/get-shape file' :root1) + comp1' (thc/get-component file' :component1)] - (t/is (= 1 (count errors))) + (t/is (= 2 (count errors))) (t/is (= :variant-main-bad-name (:code (first errors)))) + (t/is (= :variant-component-bad-name (:code (second errors)))) (t/is (nil? errors')) - (t/is (= "Board" (:name root1')))))) + (t/is (= "Board" (:name root1'))) + (t/is (= "Board" (:name comp1')))))) (t/deftest repair-variant-main-bad-variant-name (t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties" @@ -211,23 +309,28 @@ (ths/update-shape :variant1 :name "Group / Subgroup / Component") (ths/update-shape :root1 :name "Group / Subgroup / Component") (ths/update-shape :root2 :name "Group / Subgroup / Component") - ;; Update component paths and names (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) - (thc/update-component :component2 {:path "Group / Subgroup" :name "Component"}) - ;; Break component1's name - (thc/update-component :component1 {:name "WrongName"})) + ;; Break component2's name + (thc/update-component :component2 {:name "WrongName"})) errors (cfv/validate-file file {}) changes (cfr/repair-file file {} errors) file' (thf/apply-changes file {:redo-changes changes} :validate? false) errors' (cfv/validate-file file' {}) - comp1' (thc/get-component file' :component1)] + root1' (ths/get-shape file' :root1) + comp1' (thc/get-component file' :component1) + root2' (ths/get-shape file' :root2) + comp2' (thc/get-component file' :component2)] (t/is (= 1 (count errors))) (t/is (= :variant-component-bad-name (:code (first errors)))) (t/is (nil? errors')) + (t/is (= "Group / Subgroup / Component" (:name root1'))) (t/is (= "Group / Subgroup" (:path comp1'))) - (t/is (= "Component" (:name comp1')))))) + (t/is (= "Component" (:name comp1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path comp2'))) + (t/is (= "Component" (:name comp2')))))) (t/deftest repair-child-not-in-parent (t/testing "detect and repair shapes whose parent exists but does not list them" diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index 76da45bd4e..8d6a28b393 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -1494,3 +1494,218 @@ ;; set-theme-active false removes non-existent id (let [status' (cfo/set-theme-active all-invalid tokens-lib invalid-theme-id false)] (t/is (not (ctos/theme-active? status' invalid-theme-id)))))))) + +;; absorb-tokens-lib + +(defn- make-sample-tokens-lib + "A tokens-lib with a set (and a token in it) for each label. Each set + is named as its label, and its id is registered in thi under it." + [& set-labels] + (reduce (fn [tokens-lib set-label] + (let [set-id (thi/new-id! set-label)] + (-> tokens-lib + (ctob/add-set (ctob/make-token-set :id set-id + :name (name set-label))) + (ctob/add-token set-id (ctob/make-token :name (str (name set-label) "-token") + :type :border-radius + :value 10))))) + (ctob/make-tokens-lib) + set-labels)) + +(defn- make-sample-tokens-status + "A tokens-status with the sets of the given labels active. The sets + must have been created before by make-sample-tokens-lib." + [& set-labels] + (ctos/make-tokens-status :active-set-ids (into #{} (map thi/id) set-labels))) + +(defn- make-sample-data + "File data with the given tokens-lib, tokens-status and tokens-source. + Those not given are left unset." + [label & {:keys [tokens-lib tokens-status tokens-source]}] + (-> (ctf/file-data (thf/sample-file label)) + (cfo/ensure-tokens-lib) + (cfo/update-tokens-lib (constantly tokens-lib)) + (cfo/update-tokens-status (constantly tokens-status)) + (cfo/set-tokens-source tokens-source))) + +(t/deftest test-absorb-tokens-lib + (t/testing "copies the tokens-lib of the library and unsets the tokens-source" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))) + (t/is (true? (cfo/effective-tokens-source? file-data' (:id file-data')))) + (t/is (true? (cfo/editable-tokens? file-data'))))) + + (t/testing "replaces the own tokens-lib of the file" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-lib (make-sample-tokens-lib :own-set) + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "copies an empty tokens-lib of the library" + (let [empty-lib (ctob/make-tokens-lib) + library-data (make-sample-data :library :tokens-lib empty-lib) + file-data (make-sample-data :file1 + :tokens-lib (make-sample-tokens-lib :own-set) + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= empty-lib (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "unsets the tokens-source even if the file had no tokens-source" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "does not change anything else in the file" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (dissoc file-data :tokens-lib :tokens-status :tokens-source) + (dissoc file-data' :tokens-lib :tokens-status :tokens-source)))))) + +(t/deftest test-absorb-tokens-lib-status + (t/testing "preserves the tokens-status of the file" + (let [library-lib (make-sample-tokens-lib :lib-set :other-set) + file-status (make-sample-tokens-status :lib-set) + library-status (make-sample-tokens-status :other-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-status (cfo/get-tokens-status file-data'))) + (t/is (not= library-status (cfo/get-tokens-status file-data'))))) + + (t/testing "preserves an empty tokens-status of the file" + (let [library-lib (make-sample-tokens-lib :lib-set) + file-status (ctos/make-tokens-status) + library-status (make-sample-tokens-status :lib-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-status (cfo/get-tokens-status file-data'))))) + + (t/testing "copies the tokens-status of the library if the file has none" + (let [library-lib (make-sample-tokens-lib :lib-set) + library-status (make-sample-tokens-status :lib-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= library-status (cfo/get-tokens-status file-data'))) + (t/is (= #{(thi/id :lib-set)} + (ctos/get-active-set-ids (cfo/get-tokens-status file-data')))) + (t/is (some? (ctob/get-set (cfo/get-tokens-lib file-data') (thi/id :lib-set)))))) + + (t/testing "leaves the tokens-status unset if neither the file nor the library have one" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-status file-data')))))) + +(t/deftest test-absorb-tokens-lib-library-without-tokens + (t/testing "library without tokens-lib: keeps the tokens-lib and tokens-status of the file, and unsets the tokens-source" + (let [file-lib (make-sample-tokens-lib :own-set) + file-status (make-sample-tokens-status :own-set) + library-status (ctos/make-tokens-status :active-set-ids #{(uuid/next)}) + library-data (make-sample-data :library + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-lib (cfo/get-tokens-lib file-data'))) + (t/is (= file-status (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "library without tokens-lib: does not copy the tokens-status of the library" + (let [library-status (ctos/make-tokens-status :active-set-ids #{(uuid/next)}) + library-data (make-sample-data :library + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "library without any tokens attribute" + (let [library-data (ctf/file-data (thf/sample-file :library)) + file-lib (make-sample-tokens-lib :own-set) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-lib (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "neither the file nor the library have tokens" + (let [library-data (ctf/file-data (thf/sample-file :library)) + file-data (ctf/file-data (thf/sample-file :file1)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data')))))) + +(t/deftest test-absorb-tokens-lib-no-library-data + (t/testing "nil library-data: the file is returned unchanged" + (let [file-lib (make-sample-tokens-lib :own-set) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-status (make-sample-tokens-status :own-set) + :tokens-source (uuid/next))] + (t/is (identical? file-data (cfo/absorb-tokens-lib file-data nil))))) + + (t/testing "nil library-data and a file without tokens" + (let [file-data (ctf/file-data (thf/sample-file :file1))] + (t/is (identical? file-data (cfo/absorb-tokens-lib file-data nil)))))) + +(t/deftest test-detach-external-references-preserves-own-tokens + (let [file (thf/sample-file :file1) + lib (make-sample-tokens-lib :own-set) + status (make-sample-tokens-status :own-set) + file (-> file + (assoc-in [:data :tokens-lib] lib) + (assoc-in [:data :tokens-status] status))] + (doseq [source [nil (:id file)]] + (t/testing (str "local tokens source " source) + (let [file (cond-> file + source (assoc-in [:data :tokens-source] source)) + detached (ctf/detach-external-references file (:id file))] + (t/is (= source (cfo/get-tokens-source (:data detached)))) + (t/is (identical? lib (cfo/get-tokens-lib (:data detached)))) + (t/is (= status (cfo/get-tokens-status (:data detached))))))))) + +(t/deftest test-detach-external-references-removes-external-tokens-status + (let [file (-> (thf/sample-file :file1) + (assoc-in [:data :tokens-source] (uuid/next)) + (assoc-in [:data :tokens-status] (ctos/make-tokens-status))) + detached (ctf/detach-external-references file (:id file))] + (t/is (not (contains? (:data detached) :tokens-source))) + (t/is (not (contains? (:data detached) :tokens-status))) + (t/is (= detached (ctf/check-file detached))))) diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc index 707d56d3f0..23891a583b 100644 --- a/common/test/common_tests/files_builder_test.cljc +++ b/common/test/common_tests/files_builder_test.cljc @@ -70,3 +70,20 @@ (t/is (= "Image" (:name media))) (t/is (= 128 (:width media))) (t/is (= 64 (:height media))))) + +(t/deftest add-shape-normalizes-path-name + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-shape {:id shape-id + :type :frame + :name "Group/Subgroup / Name" + :x 0 + :y 0 + :width 10 + :height 10})) + shape (fb/get-shape state shape-id)] + (t/is (= "Group / Subgroup / Name" (:name shape))))) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 75f63829fd..84afec0b71 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -9,12 +9,22 @@ [app.common.data :as d] [app.common.files.migrations :as cfm] [app.common.files.tokens :as cfo] + [app.common.geom.rect :as grc] + [app.common.test-helpers.components :as thk] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.variants :as thv] [app.common.types.file :as ctf] + [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.token :as ctt] [app.common.uuid :as uuid] [clojure.test :as t])) +(t/use-fixtures :each thi/test-fixture) + (defmethod cfm/migrate-data "test/1" [data _] (update data :sum inc)) (defmethod cfm/migrate-data "test/2" [data _] (update data :sum inc)) (defmethod cfm/migrate-data "test/3" [data _] (update data :sum inc)) @@ -650,3 +660,196 @@ data' (cfm/migrate-data data migration-id)] (t/is (= [child-id] (get-in data' [:components component-id :objects main-id :shapes]))))) + +(defn- make-path-shape + [shape-id] + (cts/setup-shape {:id shape-id + :type :path + :content [{:command :move-to :params {:x 10 :y 20}} + {:command :line-to :params {:x 110 :y 70}}]})) + +(t/deftest migration-legacy-6-repairs-paths-without-points + (let [migration-id "legacy-6" + page-id (uuid/next) + shape-id (uuid/next) + shape (make-path-shape shape-id)] + + (doseq [points [nil []]] + (let [data {:pages-index {page-id {:objects {shape-id (assoc shape :points points)}}}} + data' (cfm/migrate-data data migration-id) + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (= (:points shape) (:points shape')) + (str "points rebuilt from content when they were " (pr-str points))) + (t/is (= (:selrect shape) (:selrect shape')) + "selrect rebuilt from content"))))) + +(def ^:private contents-without-rect + "Path contents from old files whose points give no rect" + {"segments without params" [{:command :move-to} {:command :line-to}] + "only a close path" [{:command :close-path :params {}}] + "not finite coordinates" [{:command :move-to :params {:x ##NaN :y ##Inf}} + {:command :line-to :params {:x 10 :y 10}}]}) + +(t/deftest migration-legacy-6-repairs-paths-whose-content-has-no-rect + (let [migration-id "legacy-6" + page-id (uuid/next) + shape-id (uuid/next)] + + (doseq [[label content] contents-without-rect] + (let [shape (-> (make-path-shape shape-id) + (assoc :points [] :content content)) + data {:pages-index {page-id {:objects {shape-id shape}}}} + data' (cfm/migrate-data data migration-id) + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (grc/rect? (:selrect shape')) (str "selrect is a rect: " label)) + (t/is (= 4 (count (:points shape'))) (str "points rebuilt: " label)))))) + +(t/deftest migration-0003-convert-path-content-drops-segments-without-coordinates + (let [migration-id "0003-convert-path-content-v2" + page-id (uuid/next) + shape-id (uuid/next) + content [{:command :move-to :params {:x 10 :y 20}} + {:command :line-to} + {:command :curve-to :params {:x 50 :y 50}} + {:command :line-to :params {:x 110 :y 70}} + {:command :close-path :params {}}] + shape (assoc (make-path-shape shape-id) :content content) + data {:pages-index {page-id {:objects {shape-id shape}}}} + data' (cfm/migrate-data data migration-id) + content' (get-in data' [:pages-index page-id :objects shape-id :content])] + + (t/is (path/content? content')) + (t/is (= [:move-to :line-to :close-path] (mapv :command (vec content')))))) + +(t/deftest migration-legacy-6-chain-with-content-without-rect + ;; The file must load: every migration from legacy-6 on and the final + ;; data validation must succeed. + (doseq [[label content] contents-without-rect] + (let [shape-id (uuid/next) + file (ctf/make-file {:name "Legacy path without rect"}) + page-id (first (get-in file [:data :pages])) + shape (-> (make-path-shape shape-id) + (assoc :points [] :content content)) + file (-> file + (dissoc :migrations) + (assoc :version 5) + (assoc-in [:data :pages-index page-id :objects shape-id] shape)) + file' (cfm/migrate-file file (delay {}))] + + (t/is (contains? (:migrations file') "legacy-6") (str "migrated: " label))))) + +(t/deftest migration-legacy-6-runs-through-file-migration + ;; A file saved before version 6 runs every migration from legacy-6 on, + ;; and the whole chain must succeed for the file to load. + (let [migration-id "legacy-6" + shape-id (uuid/next) + file (ctf/make-file {:name "Legacy path without points"}) + page-id (first (get-in file [:data :pages])) + shape (-> (make-path-shape shape-id) + (assoc :points [])) + file (-> file + (dissoc :migrations) + (assoc :version 5) + (assoc-in [:data :pages-index page-id :objects shape-id] shape)) + file' (cfm/migrate-file file (delay {}))] + + (t/is (contains? (:migrations file') migration-id) "migration id persisted") + (t/is (= 4 (count (get-in file' [:data :pages-index page-id :objects shape-id :points]))) + "path points repaired before schema validation"))) + +(t/deftest migration-0033-normalize-main-instance-diff-from-component + (let [migration-id "0033-normalize-main-instance-names" + file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :root1 :child1 + :component-params {:path "Group / Subgroup" + :name "Name"} + :root-params {:name "Group/Subgroup / Name"}) + (tho/add-simple-component :component2 :root2 :child2 + :component-params {:path "Group / Subgroup" + :name "Other Name"} + :root-params {:name "Group / Subgroup / Other Name"}) + (ths/add-sample-shape :shape1 :name "Other Group/Other Subgroup / Other Name")) + + data (ctf/file-data file) + data' (cfm/migrate-data data migration-id) + file' (assoc file :data data') + + root1' (ths/get-shape file' :root1) + component1' (thk/get-component file' :component1) + root2' (ths/get-shape file' :root2) + component2' (thk/get-component file' :component2) + shape1' (ths/get-shape file' :shape1)] + + (t/is (= "Group / Subgroup" (:path component1'))) + (t/is (= "Name" (:name component1'))) + (t/is (= "Group / Subgroup / Name" (:name root1'))) + (t/is (= "Group / Subgroup" (:path component2'))) + (t/is (= "Other Name" (:name component2'))) + (t/is (= "Group / Subgroup / Other Name" (:name root2'))) + (t/is (= "Other Group/Other Subgroup / Other Name" (:name shape1'))))) + +(t/deftest migration-0033-normalize-variant-instance-diff-from-container + (let [migration-id "0033-normalize-main-instance-names" + file (-> (thf/sample-file :file1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :variant1 :name "Group / Subgroup / Component") + (ths/update-shape :root1 :name "Group / Subgroup / Component") + (ths/update-shape :root2 :name "Group / Subgroup/Component") ;; Bad path + (thk/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thk/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + (thk/instantiate-component :component2 :copy2) + (ths/update-shape :copy2 :name "Group / Subgroup/Component")) ;; Allowed name in copies + + data (ctf/file-data file) + data' (cfm/migrate-data data migration-id) + file' (assoc file :data data') + + variant1' (ths/get-shape file' :variant1) + root1' (ths/get-shape file' :root1) + component1' (thk/get-component file' :component1) + root2' (ths/get-shape file' :root2) + component2' (thk/get-component file' :component2) + copy2' (ths/get-shape file' :copy2)] + + (t/is (= "Group / Subgroup / Component" (:name variant1'))) + (t/is (= "Group / Subgroup / Component" (:name root1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path component1'))) + (t/is (= "Group / Subgroup" (:path component2'))) + (t/is (= "Component" (:name component1'))) + (t/is (= "Component" (:name component2'))) + (t/is (= "Group / Subgroup/Component" (:name copy2'))))) + +(t/deftest migration-0033-normalize-variant-name-not-normalized + (let [migration-id "0033-normalize-main-instance-names" + file (-> (thf/sample-file :file1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :variant1 :name "Group/Subgroup / Component") + (ths/update-shape :root1 :name "Group/Subgroup / Component") + (ths/update-shape :root2 :name "Group/Subgroup / Component") ;; Bad path + (thk/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thk/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + (thk/instantiate-component :component2 :copy2) + (ths/update-shape :copy2 :name "Group / Subgroup/Component")) ;; Allowed name in copies + + data (ctf/file-data file) + data' (cfm/migrate-data data migration-id) + file' (assoc file :data data') + + variant1' (ths/get-shape file' :variant1) + root1' (ths/get-shape file' :root1) + component1' (thk/get-component file' :component1) + root2' (ths/get-shape file' :root2) + component2' (thk/get-component file' :component2) + copy2' (ths/get-shape file' :copy2)] + + (t/is (= "Group / Subgroup / Component" (:name variant1'))) + (t/is (= "Group / Subgroup / Component" (:name root1'))) + (t/is (= "Group / Subgroup / Component" (:name root2'))) + (t/is (= "Group / Subgroup" (:path component1'))) + (t/is (= "Group / Subgroup" (:path component2'))) + (t/is (= "Component" (:name component1'))) + (t/is (= "Component" (:name component2'))) + (t/is (= "Group / Subgroup/Component" (:name copy2'))))) diff --git a/common/test/common_tests/fonts_test.cljs b/common/test/common_tests/fonts_test.cljs new file mode 100644 index 0000000000..36786ccb7c --- /dev/null +++ b/common/test/common_tests/fonts_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 common-tests.fonts-test + (:require + [app.common.fonts :as cfnt] + [clojure.test :as t])) + +(defn- fallback-font-ids + [text] + (->> (cfnt/collect-used-languages #{} text) + (cfnt/add-noto-fonts #{}) + (into #{} (map :font-id)))) + +(t/deftest math-symbols-use-noto-sans-math + (t/testing "arrows" + (t/is (contains? (fallback-font-ids "↳") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "⇒") "gfont-noto-sans-math"))) + (t/testing "mathematical operators" + (t/is (contains? (fallback-font-ids "∀x ∈ A") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "a ≠ b") "gfont-noto-sans-math"))) + (t/testing "supplemental arrows and operators" + (t/is (contains? (fallback-font-ids "⟹") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "⤴") "gfont-noto-sans-math")) + (t/is (contains? (fallback-font-ids "⨁") "gfont-noto-sans-math")))) + +(t/deftest letterlike-symbols-use-noto-sans + (t/is (contains? (fallback-font-ids "x ∈ ℝ") "gfont-noto-sans")) + (t/is (contains? (fallback-font-ids "20 ℃") "gfont-noto-sans")) + (t/is (contains? (fallback-font-ids "Penpot™") "gfont-noto-sans"))) + +(t/deftest plain-text-does-not-use-noto-sans-math + (t/is (not (contains? (fallback-font-ids "Hello world") "gfont-noto-sans-math"))) + (t/is (not (contains? (fallback-font-ids "★ ☀") "gfont-noto-sans-math")))) diff --git a/common/test/common_tests/geom_image_crop_test.cljc b/common/test/common_tests/geom_image_crop_test.cljc new file mode 100644 index 0000000000..31c686fbab --- /dev/null +++ b/common/test/common_tests/geom_image_crop_test.cljc @@ -0,0 +1,95 @@ +;; 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 common-tests.geom-image-crop-test + (:require + [app.common.geom.image-crop :as gic] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [clojure.test :as t])) + +(defn- close-vals? + [a b] + (every? true? (map mth/close? a b))) + +(defn- picture-box + "Picture position and size relative to the original window start, for a + window at `offset` with `size` and the given image transform." + [{:keys [x y width height]} [dx dy] [w h]] + [(+ dx (* x w)) (+ dy (* y h)) (* width w) (* height h)]) + +(t/deftest crop-axis-test + (t/testing "shrinks the window towards the fixed edge" + ;; Top handler: the bottom edge (300) stays fixed. + (t/is (close-vals? [60 240] (gic/crop-axis 300 300 0.8)))) + + (t/testing "grows the window away from the fixed edge" + (t/is (close-vals? [-60 360] (gic/crop-axis 300 300 1.2)))) + + (t/testing "crossing the fixed edge moves the window to the other side" + ;; Top handler dragged 100px below the bottom edge. + (t/is (close-vals? [300 100] (gic/crop-axis 300 300 (/ -1 3)))) + ;; Bottom handler dragged 150px above the top edge. + (t/is (close-vals? [-150 150] (gic/crop-axis 300 0 -0.5)))) + + (t/testing "resize from the center keeps the center when crossing" + (t/is (close-vals? [75 150] (gic/crop-axis 300 150 -0.5))))) + +(t/deftest update-image-transform-test + (let [width 400 + height 300 + tf {:x 0 :y -0.2 :width 1 :height 1.4}] + + (t/testing "the picture keeps its position and size" + (doseq [[dx dy w' h'] [[0 60 400 240] + [0 300 400 100] + [0 -150 400 150] + [-50 75 500 150]]] + (let [tf' (gic/update-image-transform tf width height dx dy w' h')] + (t/is (close-vals? (picture-box tf [0 0] [width height]) + (picture-box tf' [dx dy] [w' h'])) + (str "window " [dx dy w' h']))))) + + (t/testing "a missing transform means the picture fills the window" + (t/is (close-vals? [0 -0.25 1 1.25] + ((juxt :x :y :width :height) + (gic/update-image-transform nil 400 300 0 60 400 240))))) + + (t/testing "moving the window back restores the original transform" + (let [[dy h'] (gic/crop-axis height height (/ -1 3)) + crossed (gic/update-image-transform tf width height 0 dy width h') + [dy2 h2] (gic/crop-axis h' 0 (/ height h' -1)) + back (gic/update-image-transform crossed width h' 0 dy2 width h2)] + (t/is (close-vals? [(- dy) height] [dy2 h2]) "window back at its original place") + (t/is (close-vals? ((juxt :x :y :width :height) tf) + ((juxt :x :y :width :height) back))))))) + +(t/deftest preview-image-matrix-test + (let [width 400 + height 300 + tf {:x 0 :y -0.2 :width 1 :height 1.4}] + + (t/testing "the picture keeps its place once the window is resized" + (doseq [[dx dy w' h'] [[0 60 400 240] + [0 300 400 100] + [-50 75 500 150]]] + (let [tf' (gic/update-image-transform tf width height dx dy w' h') + matrix (gic/preview-image-matrix tf tf' width height) + ;; Resize the preview applies to the whole shape: the old + ;; window goes to the new one. + window (-> (gmt/matrix) + (gmt/translate (gpt/point dx dy)) + (gmt/scale (gpt/point (/ w' width) (/ h' height)))) + [x y w h] (picture-box tf [0 0] [width height]) + p1 (gpt/transform (gpt/point x y) (gmt/multiply window matrix)) + p2 (gpt/transform (gpt/point (+ x w) (+ y h)) (gmt/multiply window matrix))] + (t/is (close-vals? [x y (+ x w) (+ y h)] [(:x p1) (:y p1) (:x p2) (:y p2)]) + (str "window " [dx dy w' h']))))) + + (t/testing "no change gives the identity" + (t/is (gmt/close? (gmt/matrix) (gic/preview-image-matrix tf tf width height))) + (t/is (gmt/close? (gmt/matrix) (gic/preview-image-matrix nil nil width height)))))) diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc index d28e5f3b31..95f158e2a3 100644 --- a/common/test/common_tests/logic/comp_creation_test.cljc +++ b/common/test/common_tests/logic/comp_creation_test.cljc @@ -19,6 +19,7 @@ [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] + [app.common.types.file :as ctf] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [clojure.test :as t])) @@ -250,18 +251,25 @@ component (thc/get-component file :component1) ;; ==== Action - changes (cll/generate-rename-component (pcb/empty-changes) - (:id component) - "Test component after" - (:data file)) + changes (-> (pcb/empty-changes nil (thf/current-page-id file)) + (pcb/with-file-data (ctf/file-data file)) + (cll/generate-rename-component + (:id component) + "Test component after" + (:data file)) + (pcb/update-shapes + [(thi/id :main1-root)] + #(assoc % :name "Test component after"))) file' (thf/apply-changes file changes) ;; ==== Get - component' (thc/get-component file' :component1)] + component' (thc/get-component file' :component1) + main1-root' (ths/get-shape file' :main1-root)] ;; ==== Check - (t/is (= (:name component') "Test component after")))) + (t/is (= (:name component') "Test component after")) + (t/is (= (:name main1-root') "Test component after")))) (t/deftest test-duplicate-component (let [;; ==== Setup 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'))))) diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 20ab2e59fb..799d895f44 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -177,7 +177,60 @@ (t/is (= (ctos/get-active-theme-ids file-tokens-status) (ctos/get-active-theme-ids undo-tokens-status))) (t/is (= (ctos/get-active-set-ids file-tokens-status) - (ctos/get-active-set-ids undo-tokens-status)))))) + (ctos/get-active-set-ids undo-tokens-status))))) + + (t/testing "if the library is the same file, themes and sets of the previous source are deactivated" + (let [set-id (uuid/next) + external-set-id (uuid/next) + external-theme-id (uuid/next) + library-id (uuid/next) + file (-> (tht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id set-id + :name "one set"))) + :status-fn #(ctos/set-tokens-status % #{external-theme-id} + #{set-id external-set-id})) + (assoc-in [:data :tokens-source] library-id)) + changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source file)) + redo (thf/apply-changes file changes) + undo (thf/apply-undo-changes redo changes) + file-tokens-status (tht/get-tokens-status file) + redo-tokens-source (tht/get-tokens-source redo) + redo-tokens-status (tht/get-tokens-status redo) + undo-tokens-source (tht/get-tokens-source undo) + undo-tokens-status (tht/get-tokens-status undo)] + + ;; Redo: only the themes and sets of the file's own lib stay active + (t/is (nil? redo-tokens-source)) + (t/is (= #{} (ctos/get-active-theme-ids redo-tokens-status))) + (t/is (= #{set-id} (ctos/get-active-set-ids redo-tokens-status))) + + ;; Undo: source and status restored to the original state + (t/is (= library-id undo-tokens-source)) + (t/is (= (ctos/get-active-theme-ids file-tokens-status) + (ctos/get-active-theme-ids undo-tokens-status))) + (t/is (= (ctos/get-active-set-ids file-tokens-status) + (ctos/get-active-set-ids undo-tokens-status))))) + + (t/testing "if the library is the same file and it has no own tokens lib, the tokens status is cleared" + (let [set-id (uuid/next) + file (-> (thf/sample-file :file1) + (update :data dissoc :tokens-lib) + (assoc-in [:data :tokens-source] (uuid/next)) + (assoc-in [:data :tokens-status] (ctos/set-tokens-status (ctos/make-tokens-status) + #{} #{set-id}))) + changes (-> (pcb/empty-changes) + (pcb/with-library-data (ctf/file-data file)) + (clt/generate-set-tokens-source file)) + redo (thf/apply-changes file changes) + redo-tokens-status (tht/get-tokens-status redo)] + + (t/is (nil? (tht/get-tokens-source redo))) + (t/is (= #{} (ctos/get-active-theme-ids redo-tokens-status))) + (t/is (= #{} (ctos/get-active-set-ids redo-tokens-status)))))) ;; Tokens lib diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index 00bbe932ef..f94525cae7 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -35,7 +35,12 @@ (t/is (= 400 (media/parse-font-weight "Lighthaus"))) (t/is (= 400 (media/parse-font-weight "Blackwood"))) (t/is (= 400 (media/parse-font-weight "Thinker"))) - (t/is (= 400 (media/parse-font-weight "Mediaeval"))))) + (t/is (= 400 (media/parse-font-weight "Mediaeval")))) + + (t/testing "defaults to regular when the variant metadata is missing" + (t/is (= 400 (media/parse-font-weight nil))) + (t/is (= 400 (media/parse-font-weight ""))) + (t/is (= 400 (media/parse-font-weight " "))))) (t/deftest test-parse-font-style (t/testing "matches italic with proper boundaries" @@ -47,7 +52,12 @@ (t/testing "does not match italic embedded in words" (t/is (= "normal" (media/parse-font-style "Italica"))) - (t/is (= "normal" (media/parse-font-style "Roboto-Regular"))))) + (t/is (= "normal" (media/parse-font-style "Roboto-Regular")))) + + (t/testing "defaults to normal when the variant metadata is missing" + (t/is (= "normal" (media/parse-font-style nil))) + (t/is (= "normal" (media/parse-font-style ""))) + (t/is (= "normal" (media/parse-font-style " "))))) (t/deftest test-strip-image-extension (t/testing "removes extension from supported image files" diff --git a/common/test/common_tests/path_names_tree_test.cljc b/common/test/common_tests/path_names_tree_test.cljc new file mode 100644 index 0000000000..b39b5fc0b2 --- /dev/null +++ b/common/test/common_tests/path_names_tree_test.cljc @@ -0,0 +1,97 @@ +;; 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 common-tests.path-names-tree-test + (:require + [app.common.files.tokens :as cfo] + [app.common.path-names :as cpn] + [app.common.types.tokens-lib :as ctob] + [clojure.test :as t])) + +(defn- single-chain-terminal + "Follows the first child down a single-token tree to its terminal node." + [root] + (loop [node root] + (if (:children node) + (recur (first (:children node))) + node))) + +(defn- tree-payloads + "Collects every node and :leaf map in a tree for marker-leak checks." + [node] + (cons node + (concat (when (:leaf node) [(:leaf node)]) + (mapcat tree-payloads (:children node))))) + +(defn- tree-leaves + "Collects every token leaf from a tree." + [nodes] + (mapcat (fn [node] + (concat (when (:leaf node) [(:leaf node)]) + (tree-leaves (:children node)))) + nodes)) + +(t/deftest build-tree-root-preserves-repeated-segments + (t/testing "#11588: adjacent repeated path segments keep their depth" + (let [tree (cpn/build-tree-root [{:name "test.pointer.pointer" :id "t1"}] ".") + terminal (single-chain-terminal (first tree))] + (t/is (= "test" (-> tree first :name))) + (t/is (= "pointer" (-> tree first :children first :name))) + (t/is (nil? (-> tree first :children first :leaf))) + (t/is (= "pointer" (:name terminal))) + (t/is (= "test.pointer.pointer" (:path terminal))) + (t/is (= {:name "pointer" :id "t1"} (:leaf terminal))) + (t/is (nil? (:children terminal))) + (t/is (every? #(not (contains? % :app.common.path-names/remaining-path)) + (tree-payloads (first tree)))))) + (t/testing "control case without repeated segments is unchanged" + (let [tree (cpn/build-tree-root [{:name "test.thing.other-thing" :id "t2"}] ".") + terminal (single-chain-terminal (first tree))] + (t/is (= "test" (-> tree first :name))) + (t/is (= "thing" (-> tree first :children first :name))) + (t/is (= "other-thing" (:name terminal))) + (t/is (= {:name "other-thing" :id "t2"} (:leaf terminal))))) + (t/testing "repeated and non-repeated siblings coexist" + (let [tree (cpn/build-tree-root [{:name "test.pointer.pointer" :id "t1"} + {:name "test.thing.other-thing" :id "t2"}] ".") + children (-> tree first :children)] + (t/is (= ["pointer" "thing"] (mapv :name children))) + (t/is (= {:name "pointer" :id "t1"} (-> children first :children first :leaf))))) + (t/testing "longer repeated runs keep every depth" + (doseq [[path id] [["test.test.pointer" "t3"] ["test.test.test" "t4"] + ["a.a" "t5"] ["a.b.b" "t6"] ["a.a.a" "t8"]]] + (let [segments (cpn/split-path path :separator ".") + root (first (cpn/build-tree-root [{:name path :id id}] ".")) + terminal (single-chain-terminal root)] + (t/is (= (last segments) (:name terminal)) path) + (t/is (= (inc (count segments)) (count (tree-payloads root))) path) + (t/is (= {:name (last segments) :id id} (:leaf terminal)) path))))) + +(t/deftest build-tree-root-preserves-active-set-ancestor-and-descendant + (t/testing "#11588: an ancestor token and its descendant in the active set are both kept" + (let [tokens-lib (ctob/read-multi-set-dtcg + {"Global" {"a.a" {"$value" "#aaa" "$type" "color"} + "a.a.a" {"$value" "#bbb" "$type" "color"}} + "$themes" [] + "$metadata" {"tokenSetOrder" ["Global"] + "activeSets" ["Global"]}}) + tokens-status (cfo/make-tokens-status-from-lib tokens-lib) + active-tokens (cfo/get-tokens-in-active-sets tokens-status tokens-lib) + tree (cpn/build-tree-root (vec (vals active-tokens)) ".") + collision (-> tree first :children first) + leaves (tree-leaves tree)] + (t/is (= #{"a.a" "a.a.a"} (set (keys active-tokens)))) + (t/is (= 2 (count leaves))) + (t/is (= #{(get-in active-tokens ["a.a" :id]) + (get-in active-tokens ["a.a.a" :id])} + (set (map :id leaves)))) + (t/is (= "a.a" (:path collision))) + (t/is (= (get-in active-tokens ["a.a" :id]) + (get-in collision [:leaf :id]))) + (t/is (= (get-in active-tokens ["a.a.a" :id]) + (get-in collision [:children 0 :leaf :id]))) + (t/is (every? #(not (contains? % :app.common.path-names/remaining-path)) + (mapcat tree-payloads tree)))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 03918613bf..bc5d10588f 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -10,12 +10,14 @@ #?(:cljs [app.common.logging :as l]) #?(:cljs [clojure.string :as str]) #?(:cljs [clojure.tools.cli :refer [parse-opts]]) + #?(:cljs [common-tests.fonts-test]) #?(:cljs [goog.object :as gobj]) [clojure.test :as t] [common-tests.attrs-test] [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] + [common-tests.exceptions-test] [common-tests.files-branch-merge-test] [common-tests.files-builder-test] [common-tests.files-changes-test] @@ -35,6 +37,7 @@ [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] [common-tests.geom-image-bounds-resize-test] + [common-tests.geom-image-crop-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] [common-tests.geom-modifiers-test] @@ -79,6 +82,7 @@ [common-tests.math-test] [common-tests.media-test] [common-tests.path-names-test] + [common-tests.path-names-tree-test] [common-tests.record-test] [common-tests.schema-test] [common-tests.spec-test] @@ -119,8 +123,10 @@ 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test + 'common-tests.exceptions-test 'common-tests.files-branch-merge-test 'common-tests.files-builder-test + #?(:cljs 'common-tests.fonts-test) 'common-tests.files-changes-test 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-0026-test @@ -138,6 +144,7 @@ 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test 'common-tests.geom-image-bounds-resize-test + 'common-tests.geom-image-crop-test 'common-tests.geom-line-test 'common-tests.geom-modif-tree-test 'common-tests.geom-modifiers-test @@ -182,6 +189,7 @@ 'common-tests.math-test 'common-tests.media-test 'common-tests.path-names-test + 'common-tests.path-names-tree-test 'common-tests.record-test 'common-tests.schema-test 'common-tests.spec-test diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 101438f4d8..ba6b51f2ca 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:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -66,8 +66,8 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.21.0 \ - PNPM_VERSION=12.9.1 \ +ENV NODE_VERSION=v24.22.1 \ + PNPM_VERSION=12.15.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -76,16 +76,16 @@ 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='33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3'; \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='468294b77633d29889c6e4a79cefb1277eba30cd62e7d6a754218cb85a1d730d'; \ + 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='6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff'; \ + NODE_SHA256='eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c'; \ PNPM_ARCH='x64'; \ - PNPM_SHA256='b4f58449ac02d9d24023ac89e17e05e8331f8ddc12ca1f489cf231bc2bb2bce1'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -106,37 +106,6 @@ RUN set -eux; \ find /opt/node/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; ; \ rm -f /tmp/nodejs.tar.gz /tmp/pnpm.tar.gz; -################################################################################ -## OPENCODE SETUP -################################################################################ - -FROM base AS setup-opencode - -ENV OPENCODE_VERSION=2.0.26 - -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE_VERSION}.tgz"; \ - ESUM='1d7117dbb31968456f89537b562fb5e20e48d9e9ef0325fc8845abdda6ef4842'; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE_VERSION}.tgz"; \ - ESUM='0a116e033a02804741d4644337d012bdf5a24aac33a34c0c96534d2d53961119'; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; exit 1; \ - ;; \ - esac; \ - curl -LfsSo /tmp/opencode.tgz "${BINARY_URL}"; \ - echo "${ESUM} */tmp/opencode.tgz" | sha256sum -c -; \ - mkdir -p /tmp/opencode; \ - tar -xzf /tmp/opencode.tgz -C /tmp/opencode --strip-components=2 package/bin; \ - chmod +x /tmp/opencode/opencode; \ - rm -f /tmp/opencode.tgz; - - ################################################################################ ## CADDYSERVER SETUP ################################################################################ @@ -257,17 +226,19 @@ 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 \ - 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)"; \ @@ -378,11 +349,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}"; \ @@ -561,7 +532,6 @@ COPY --from=setup-rust /opt/cargo /opt/cargo COPY --from=setup-rust /opt/rustup /opt/rustup COPY --from=setup-rust /opt/emsdk /opt/emsdk COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy -COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/nginx-mime.types /etc/nginx/mime.types 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..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=1.6.1 + - SERENA_UPDATE_VERSION=2.1.0 - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 5097ab010e..5fb66c9613 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -27,7 +27,7 @@ fi # pnpm export PNPM_HOME="/home/penpot/.local/share/pnpm" case ":$PATH:" in - *":$PNPM_HOME:"*) ;; - *) export PATH="$PNPM_HOME:$PATH" ;; + *":$PNPM_HOME/bin:"*) ;; + *) export PATH="$PNPM_HOME/bin:$PATH" ;; esac # pnpm end diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index d7a00a670a..193005d3fe 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -10,6 +10,10 @@ cp /root/.bashrc /home/penpot/.bashrc cp /root/.vimrc /home/penpot/.vimrc cp /root/.tmux.conf /home/penpot/.tmux.conf +chown -R penpot:penpot /home/penpot/.bashrc +chown -R penpot:penpot /home/penpot/.vimrc +chown -R penpot:penpot /home/penpot/.tmux.conf + # Seed SERENA_HOME with default config on first run mkdir -p ${SERENA_HOME} if [ ! -f "${SERENA_HOME}/serena_config.yml" ]; then @@ -21,6 +25,16 @@ chown penpot:users /home/penpot # we need to be able to install rust-analyzer and possibly other dependencies with rustup chown -R penpot:penpot /opt/rustup +# pnpm keeps its global dir (bin, global, store, package-manager-store) +# under ~/.local and its metadata cache under ~/.cache; opencode and other +# user-space tools write there too. Hand both trees back to penpot so +# packages, and pnpm itself, can be updated without root. Needed because the +# UID remap above can leave everything the image created owned by the old +# UID. ~/.config is deliberately excluded: the opencode overlay bind-mounts +# a host directory inside it. +mkdir -p /home/penpot/.local /home/penpot/.cache +chown -R penpot:penpot /home/penpot/.local /home/penpot/.cache + rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/ export JAVA_OPTS="-Djava.net.preferIPv4Stack=true" diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index bfb5544c7d..f875c09a95 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: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 4937b04cda..32e8c2c1fa 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:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -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}"; \ @@ -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:9b025768946856e44f9775005eb199f2e1820484ea27bb5c8bcbb187b80464dc AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 03d468e72e..97daca2dfe 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.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -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.8.1 +ARG PNPM_VERSION=12.15.0 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ PNPM_ARCH='arm64'; \ - PNPM_SHA256='75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 6e913f5919..26f84d4640 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:940ee353dd807f5c5e32720923bb9b34ef9e631ae567320f3eaea7797d6c7964 LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index d175718177..f018302d33 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.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 AS build LABEL maintainer="Penpot " ENV DEBIAN_FRONTEND=noninteractive @@ -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.8.1 +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='75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -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.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 20831a6798..ee0694bc34 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.1-debian13-dev@sha256:20cf36e12660c68b1e28887d1e71a7f2494bcea9d034709368e0afce77728eb0 LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ @@ -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.8.1 +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='75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1'; \ + PNPM_SHA256='d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117'; \ ;; \ amd64|x86_64) \ PNPM_ARCH='x64'; \ - PNPM_SHA256='db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8'; \ + PNPM_SHA256='55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ 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/" 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..a2a90913b0 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,7 +1,11 @@ +# 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 { + 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$request_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 3ec97403a8..d60a5d691f 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -51,6 +51,23 @@ 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. +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%/}/" + +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 ^${_public_path_regex}(.*)\$ /\$1 last;" + unset _public_path_regex +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,7 +75,7 @@ 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 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/img/libraries/libraries-import-tokens-button.webp b/docs/img/libraries/libraries-import-tokens-button.webp deleted file mode 100644 index 6cfe1b2156..0000000000 Binary files a/docs/img/libraries/libraries-import-tokens-button.webp and /dev/null differ diff --git a/docs/img/libraries/libraries-import-tokens-confirm.webp b/docs/img/libraries/libraries-import-tokens-confirm.webp deleted file mode 100644 index cc923ac079..0000000000 Binary files a/docs/img/libraries/libraries-import-tokens-confirm.webp and /dev/null differ diff --git a/docs/img/libraries/libraries-tokens-source.webp b/docs/img/libraries/libraries-tokens-source.webp new file mode 100644 index 0000000000..3a7b15f9cd Binary files /dev/null and b/docs/img/libraries/libraries-tokens-source.webp differ diff --git a/docs/img/shortcuts/shortcuts-settings.webp b/docs/img/shortcuts/shortcuts-settings.webp new file mode 100644 index 0000000000..e552a0f87a Binary files /dev/null and b/docs/img/shortcuts/shortcuts-settings.webp differ diff --git a/docs/img/styling/stroke-per-side.webp b/docs/img/styling/stroke-per-side.webp new file mode 100644 index 0000000000..676be32b83 Binary files /dev/null and b/docs/img/styling/stroke-per-side.webp differ diff --git a/docs/img/teams/team-selector-organizations.webp b/docs/img/teams/team-selector-organizations.webp new file mode 100644 index 0000000000..8996358acc Binary files /dev/null and b/docs/img/teams/team-selector-organizations.webp differ diff --git a/docs/img/teams/team-selector.webp b/docs/img/teams/team-selector.webp deleted file mode 100644 index e7af5e1523..0000000000 Binary files a/docs/img/teams/team-selector.webp and /dev/null differ diff --git a/docs/package.json b/docs/package.json index e683aea84e..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } 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/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index fcad8e2db3..460dc46c55 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 695a7e3a02..91c6ab740d 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. @@ -740,7 +735,7 @@ flags, and by whether `PENPOT_PUBLIC_URI` is served over HTTPS. See ### Database -Penpot only supports PostgreSQL and we highly recommend >=13 version. If you are using official +Penpot only supports PostgreSQL and we highly recommend >=15 version. If you are using official docker images this is already solved for you. Essential database configuration: @@ -833,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. @@ -926,6 +923,15 @@ PENPOT_INTERNAL_URI: http://penpot-frontend:8080 `http://penpot-frontend:8080` used in the docker-compose is a good default and it is recommended to keep it unchanged. +**Beta, new render engine (WebGL):** when you run more than one exporter, give +each one its own name and keep it across restarts and upgrades. An exporter +uses it to cancel only its own unfinished exports when it restarts: + +```bash +# Exporter +PENPOT_EXPORTER_REPLICA_ID: exporter-1 +``` + ### MCP The MCP server lets AI agents read and edit Penpot files. It runs as a separate 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 diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk index 64c39376d3..ea8b721bb4 100644 --- a/docs/user-guide/account-teams/teams.njk +++ b/docs/user-guide/account-teams/teams.njk @@ -14,18 +14,24 @@ member is allowed to do depends on their permissions.

At Penpot you can create as many teams as you need and be invited to teams owned by others. Learn how to manage them.

Select team

-

At the top left of the dashboard you can find the team selector.

+

At the top left of the dashboard you can find the team selector. It shows the current team and, if the team belongs to an organization, the organization name.

+

If you belong to an organization, the menu has two columns:

+
    +
  • Organizations: the organizations you belong to, plus Other teams for teams outside any organization. A check mark shows where you are.
  • +
  • Teams: the teams of the organization you point at, with Personal Projects at the end. Click a team to go to it. Ctrl/⌘ + click opens it in a new tab.
  • +
+

Clicking an organization only shows its teams. You stay where you are until you pick a team. If you do not belong to any organization, the menu shows a single list of teams.

"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.

-
Teams selector
+
Team selector with the Organizations and Teams columns

Create teams

-

To create a new team go to the bottom of the team selector and press "+ Create new team". Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

+

To create a new team go to the bottom of the team selector and press "Create new team". If the menu shows organizations, the team is created in the organization you are looking at, or outside any organization if Other teams is selected. Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

Teams selector

Delete and leave teams

All members can leave the team anytime from the same menu.

-

Only the team owner can delete a team. The option can be found at the team menu (the three dots at the right side). When deleting a team all projects and files belonging to it will be permanently deleted.

+

Only the team owner can delete a team. The option can be found at the team menu (the three dots next to the team selector), together with members, invitations, settings and leaving the team. When deleting a team all projects and files belonging to it will be permanently deleted.

Teams selector

Team settings

diff --git a/docs/user-guide/account-teams/your-account.njk b/docs/user-guide/account-teams/your-account.njk index db8c5161e4..3267ac5886 100644 --- a/docs/user-guide/account-teams/your-account.njk +++ b/docs/user-guide/account-teams/your-account.njk @@ -18,11 +18,18 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi

Password #

-

If you want to change your password to a new one, this can be done in the Password section.

+

If you want to change your password to a new one, this can be done in the Password section. Penpot sends you an email each time your password is changed, set or reset.

Penpot's password
+

Sessions and sign-in protection + # +

+

Your session ends after a period without activity, and also after a maximum time since you logged in, even if you keep working. When that happens, Penpot asks you to log in again.

+

After several failed login attempts in a short time, your account can be locked for some minutes. Penpot shows how long to wait. Resetting your password unlocks it.

+

On self-hosted installations, the administrator sets these limits. See Session expiration and Account lockout in the Technical Guide.

+

Notifications #

diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index 53f58cdb99..1ceb140067 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -19,6 +19,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

Penpot Design Tokens adhere to the Design Tokens Format Module and its definitions, a draft by the W3C DTCG. Penpot ensures compatibility across various disciplines, tools, and technologies by following the most standardized approach available for design tokens.

Tokens can be exported from Penpot or integrated into other tools directly, without conversion. Additionally, the knowledge gained from using Design Tokens in Penpot remains valuable, regardless of whether you continue using Penpot or a different tool or technology.

+

Tokens live in a file. To share them between files, keep them in a shared library and set that library as the tokens source of the other files. See Use tokens from a shared library.

+

Creating a token

You can create reusable and semantic tokens to be referenced in your designs at the Tokens panel. In this panel, you’ll find all the available types of tokens in Penpot arranged alphabetically, with existing tokens being shown at the top of the list.

@@ -194,6 +196,7 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
  • Clicking on a color token will apply it to the element as a fill by default.
  • Right-clicking on a color token, allows you to select whether it should be applied as a fill or stroke color.
  • +

    You can also use a color token for the canvas background. With nothing selected, click the swatch in the Canvas background section of the Design tab, switch the color picker to Color tokens and pick a token. The background then follows the token, for example when you switch between a light and a dark theme. Use Detach token to keep the color as a plain value.

    Tokens color
    @@ -343,6 +346,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

    Stroke Width

    The stroke width token, also known as the border width token, defines the thickness of a stroke around a design element. It can be applied to boards, groups, rectangles and text elements.

    +

    On rectangles and boards you can apply it to a single side. Right-click the token and choose All, Top, Right, Bottom or Left. Dimension tokens offer the same options under Stroke Width. You can also pick a token in each side field of the stroke per side controls.

    +

    Tokens apply only to the first stroke of a layer. Applying a token to one side requires WebGL rendering.

    Number token

    @@ -677,7 +682,7 @@ ExtraBold Italic

    Importing and Exporting Tokens

    You can export Tokens from Penpot and import them from your computer to a Penpot file. Tokens can be imported from the Tools option at the bottom of the Tokens tab.

    -

    You can also import tokens from a connected shared library using the Libraries modal. That import uses the library's full token definition (sets and themes included) and replaces the tokens already in your file.

    +

    To use the tokens of a shared library without copying them, set the library as the file's tokens source. See Use tokens from a shared library. Import is not available while a library is the tokens source.

    The Import functionality allows you to upload and replace the global token set using a single file or a folder with multiple files in it.

    These features support JSON files formatted according to specific guidelines and preserve the ability to undo changes if needed.

    diff --git a/docs/user-guide/design-systems/libraries.njk b/docs/user-guide/design-systems/libraries.njk index 9c60b52918..d4b66e692e 100644 --- a/docs/user-guide/design-systems/libraries.njk +++ b/docs/user-guide/design-systems/libraries.njk @@ -71,22 +71,30 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana Open libraries
    -

    Import tokens from a connected library

    -

    If a shared library you have connected includes design tokens, you can copy them into the current file from the Libraries modal.

    -

    The Import tokens button appears only next to connected libraries that define tokens. Libraries without tokens do not show this control.

    +

    Use tokens from a shared library

    +

    A file can use the design tokens of a connected shared library instead of its own. This keeps one source of truth: tokens are defined once in the library, and every file that uses it gets the same tokens, sets and themes.

    +

    A file uses one tokens source at a time: its own tokens or the tokens of one connected library.

      -
    1. Open the Libraries panel from the assets tab.
    2. -
    3. Under Libraries in this file, find the connected library that includes tokens and click Import tokens (beside the disconnect control).
    4. -
    5. Review the confirmation dialog. Importing brings in the library's full token data, including sets and themes when the library defines them.
    6. -
    7. Click Import tokens to apply, or Cancel to go back without changes.
    8. +
    9. Connect the library to the file (see Connect shared libraries).
    10. +
    11. Open the Libraries panel from the assets tab. The This file tab lists the file library and the connected libraries, with how many tokens, sets and themes each one has.
    12. +
    13. Click Set as tokens source next to the library. The current source is labelled Tokens source.
    -

    Importing tokens from a library replaces all tokens, sets and themes in the current file. Export your tokens first (from the Tokens tab → Tools → Export) if you need to keep a backup.

    -

    This is separate from connecting the library for components, colors and typographies. You can use shared assets without importing tokens, and import tokens only when you want the file's token setup to match the library.

    - Import tokens button in the Libraries modal -
    The Import tokens button on a connected library that includes tokens.
    -
    -
    - Import tokens from library confirmation dialog -
    Confirm before importing. All current tokens, sets and themes in this file will be replaced.
    + This file tab of the Libraries modal with a connected library set as tokens source
    +

    The button only appears for libraries that have tokens of their own. A library that already uses tokens from another library cannot be a tokens source.

    +

    You can also change the source from the top of the Tokens tab, which shows Source: followed by This file or the library name. In the Assets tab, a tokens icon marks the library that is the current source.

    + +
    What you can do with library tokens
    +
      +
    • Apply them to layers, as with local tokens.
    • +
    • Activate themes and sets. The active themes and sets are saved in each file, so two files can use the same library with different themes. When you set a library as source, the file starts with the themes and sets active in the library.
    • +
    • Edit them in the library. From a file that uses library tokens you cannot create, edit, rename or delete tokens, sets or themes. Use the open-in-new-tab button next to the source name, or Edit themes in the theme menu, to open the library on its Tokens tab.
    • +
    + +
    How changes reach your file
    +

    Token changes in the library reach the files that use it right away. You do not need to accept them in Updates, which only lists components, colors and typographies. When sets or themes are added, removed or renamed in the library, Penpot shows a notice in the file.

    +

    If a token applied in your file is renamed or deleted in the library, the layer keeps its value but the token appears as missing. Apply the new token again.

    + +
    Switching back to the file's own tokens
    +

    Click Set as tokens source on the File library row. The file's own tokens were kept while the library was the source, so they come back. Tokens applied from the library appear as missing unless the file has tokens with the same names. Disconnecting a library that is the tokens source also switches the file back to its own tokens.

    diff --git a/docs/user-guide/designing/color-stroke.njk b/docs/user-guide/designing/color-stroke.njk index bd1cab3000..de69f59b8f 100644 --- a/docs/user-guide/designing/color-stroke.njk +++ b/docs/user-guide/designing/color-stroke.njk @@ -122,6 +122,21 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien Multiple strokes
    +

    Stroke per side

    +

    Rectangles and boards can have a different stroke width on each side. Use it for dividers, tab underlines or accent borders without adding extra layers.

    +
      +
    1. Select a rectangle or a board with a stroke.
    2. +
    3. Click the Stroke per side button at the end of the stroke row.
    4. +
    5. Set the width of each side: Top, Right, Bottom and Left. Use 0 to hide a side.
    6. +
    +
    + Stroke section with Stroke per side enabled and a different width on each side +
    +

    When the sides have different widths, the main width field shows Mixed. Typing a value in it sets all four sides to that value. Color, position and style are shared by all sides.

    +

    Each stroke of a layer has its own per-side widths. You can also edit them on a multiple selection of rectangles and boards.

    +

    You can apply stroke width tokens to one side or to all of them. In the Inspect tab, per-side widths appear as CSS logical properties (border-block-start-width, border-inline-end-width, border-block-end-width and border-inline-start-width).

    +

    Stroke per side requires WebGL rendering. With the classic renderer the button is disabled and only the top width is drawn.

    +

    Stroke to path

    A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.

    Stroke to path turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.

    diff --git a/docs/user-guide/designing/layers.njk b/docs/user-guide/designing/layers.njk index b46bc11f90..9aca80ced0 100644 --- a/docs/user-guide/designing/layers.njk +++ b/docs/user-guide/designing/layers.njk @@ -22,7 +22,7 @@ are shown by default at the Curves (freehand)

    The curve tool allows a path to be created directly in a freehand mode. Select the curve tool from the free-draw flyout in the toolbar or press Shift/⇧ + C.

    -

    The path created will contain a lot of points, but it is edited the same way as any other curve.

    +

    When you release the mouse, Penpot turns your stroke into a few smooth curves. Sharp turns stay as corners, so a zigzag keeps its points. The result is a regular path that you can edit like any other.

    Paths (bezier)

    A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press P. Then you have two ways to create the path:

    @@ -178,16 +178,37 @@ Select the curve tool from the free-draw flyout in the toolbar or press Shi
  • Close it clicking over the starting node.
  • Leave it open pressing Esc or Enter to stop editing. Then press Esc to exit the edit mode.
  • -

    Tip: If you hold Shift/⇧ while adding nodes the angle between the current and the next will change in 45 degree increments.

    +

    Tip: If you hold Shift/⇧ while adding nodes, the angle between the current node and the next one changes in 15 degree increments.

    -

    Edit nodes

    +

    Edit nodes

    To edit a node double click on a path or select and press Enter. You can choose to edit individual nodes or create new ones. Press Esc to exit node edition.

    +

    The path toolbar has two modes:

    +
      +
    • Move nodes (M): select and move nodes, segments and handles. Editing an existing path starts in this mode.
    • +
    • Draw nodes (P): add new nodes and segments to the path.
    • +
    +

    Snap nodes (Ctrl/⌘ + ') is on by default, so new and moved nodes snap to the existing ones.

    + +

    Continue or close a path

    +

    To keep drawing from an existing node, select it in Move mode and switch to Draw mode, or click it in Draw mode. If the node is the end of an open path, the new segments extend it. Click the start node to close the shape. Hold Shift/⇧ to draw in 15 degree steps.

    + +

    Reshape with the mouse

    +

    These gestures work on nodes and segments while you edit a path:

    +
      +
    • Ctrl/⌘ + drag a segment: bends it while both end nodes stay in place. Ctrl/⌘ + click turns a segment from straight to curved and back.
    • +
    • Ctrl/⌘ + drag a node: pulls out a pair of handles. Ctrl/⌘ + click turns a node from corner to curve and back.
    • +
    • Alt/⌥ + click a segment (Move mode): adds a node at that point. You can also click near the middle of a segment to add a node there.
    • +
    • Alt/⌥ + click a node: removes the node and keeps the path smooth around it.
    • +
    • Ctrl/⌘ + Alt/⌥ + click a node: removes the node and its segments, which leaves a gap.
    • +
    +

    Delete or Backspace removes the selected nodes and keeps the path joined. With selected handles, it removes them and the curve at that node becomes straight. Shift/⇧ + Delete removes the selected nodes together with their segments.

    +

    See all the path editor shortcuts.

    +

    Change the image frame

    +

    Hold Ctrl/⌘ while you drag a resize handle to change the layer bounds without scaling the image. Shrink the layer to crop the image, or make it bigger to add empty space around it. The image is not distorted.

    +

    This works with image layers and with any layer that has an image fill. A normal resize afterwards scales the frame and the image together.

    +

    Layer actions

    Create

    @@ -477,7 +502,7 @@ You can choose to edit individual nodes or create new ones. Press Esc
  • Layer blur blurs the layer content itself.
  • Background blur blurs whatever sits behind the layer. Use it for frosted-glass or depth effects. Background blur requires the WebGL renderer to be enabled.
  • -

    Background blur is not included in exported files yet (PNG, SVG, PDF, and others). The exporter still uses the legacy SVG renderer, so the effect only shows in the workspace and View mode. Export support is a current priority and will land in an upcoming release.

    +

    Background blur is included in exports made with WebGL rendering enabled. In PDF files it is embedded as an image.

    Colorful shapes behind a frosted panel with background blur applied
    Background blur affects the layers behind the shape, not the shape itself.
    diff --git a/docs/user-guide/designing/text-typo.njk b/docs/user-guide/designing/text-typo.njk index 3317821ff2..c01b634653 100644 --- a/docs/user-guide/designing/text-typo.njk +++ b/docs/user-guide/designing/text-typo.njk @@ -24,6 +24,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe

    Edit and style text content

    Press Enter with a text layer selected to start editing the text content. You can style parts of the text content as rich text.

    +

    With WebGL rendering enabled, right-click while editing to open a menu with Cut, Copy, Paste and Select all. Your text selection is kept.

    editing text
    diff --git a/docs/user-guide/designing/workspace-basics.njk b/docs/user-guide/designing/workspace-basics.njk index c0547d4cbc..bcd23eb546 100644 --- a/docs/user-guide/designing/workspace-basics.njk +++ b/docs/user-guide/designing/workspace-basics.njk @@ -185,6 +185,9 @@ press Shift/⇧ + left click over the right arrow of a group or a boa Marking comments as read
    +

    Filter comments

    +

    The comments menu lets you show all comments, only your comments or only your mentions. Hide resolved comments is a separate option. Penpot remembers it in your browser for all files, in the workspace and in View mode.

    +

    Edit and remove comments

    At the top right of the comment popup you can find options to edit or delete comments.

    diff --git a/docs/user-guide/export-import/exporting-layers.njk b/docs/user-guide/export-import/exporting-layers.njk index c8ad0988e9..6f1f5869c5 100644 --- a/docs/user-guide/export-import/exporting-layers.njk +++ b/docs/user-guide/export-import/exporting-layers.njk @@ -27,7 +27,7 @@ desc: Learn how to export layers in Penpot, the free, open-source design collabo

    Export options

    The options of an export:

      -
    • Size - Options for the most common sizing scales.
    • +
    • Size - The scale of the exported image: 0.5x, 0.75x, 1x, 1.5x, 2x, 3x, 4x or 6x. Use 1x, 2x and 3x to get the usual iOS asset sizes. Available for PNG, JPEG and WEBP.
    • Suffix - Especially useful if you are exporting at different scales.
    • File format - PNG, JPEG, WEBP, SVG, PDF.
    diff --git a/docs/user-guide/first-steps/cloud-selfhost.njk b/docs/user-guide/first-steps/cloud-selfhost.njk index bf3c128132..b5a22ee1a1 100644 --- a/docs/user-guide/first-steps/cloud-selfhost.njk +++ b/docs/user-guide/first-steps/cloud-selfhost.njk @@ -15,3 +15,7 @@ desc: Start instantly with Penpot, the open-source design and prototyping platfo

    On your own server

    Currently, private Penpot instances only require basic Docker knowledge.

    You can run your own Penpot server following the instructions at the Technical Guide

    + +

    Check for updates

    +

    To see if your installation is up to date, open your profile menu in the dashboard and go to About Penpot → Check for updates. If a newer version exists, Penpot shows your installed version, the latest one, and the highlights of each release you are missing, with links to the full changelog and the release notes.

    +

    The check runs from your browser and needs access to GitHub. It is hidden on installations configured as air-gapped.

    diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index b06057a9b9..ab8279486a 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -10,7 +10,45 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Here you can find a list of all keyboard shortcuts that you can use in Penpot. Bear in mind that most of them are at the workspace, where they are more needed.

    -

    You can also check the most updated list of shortcuts at the GitHub file.

    +

    The tables below list the default shortcuts. You can change most of them to fit your keyboard layout or your habits. See Customize shortcuts.

    + +

    Customize shortcuts

    +

    Go to Your account → Shortcuts to see every shortcut and change the ones you want. From the workspace, open the shortcuts panel (Help & info → Shortcuts in the main menu, or ?) and click Customize in Settings.

    +

    Your shortcuts are saved in your account, so they follow you to any computer and browser. After a change, reload any other open Penpot tabs to apply it.

    + +

    Find a shortcut

    +

    Shortcuts are grouped into Workspace, Dashboard and Viewer. Use the tabs to see All of them, only your Custom ones, or the ones you Disabled.

    +

    The search field matches both the action name and the keys. Type "group" to find the grouping actions, or "shift" to see every shortcut that uses Shift.

    + +

    Change a shortcut

    +
      +
    1. Click the shortcut you want to change.
    2. +
    3. Press the new keys. The last combination you press is the one recorded.
    4. +
    5. Click the check mark to save, or the cross to cancel. Clicking outside the editor also cancels. Esc does not cancel: it is recorded as a key.
    6. +
    +
    + Shortcuts settings with a shortcut being edited and a conflict warning +
    +

    If the keys are already used by another action in the same group, Penpot shows which one. When you save, the shortcut moves to the action you are editing, and the other action appears under Disabled.

    +

    Each action takes one combination. If an action had two defaults (for example, two ways to redo), saving a custom one replaces both.

    +

    From the same editor you can also disable a shortcut so that no keys trigger it, or reset it to its default value. Custom shortcuts are marked with a dot, and the main menu highlights the custom keys.

    +

    Paste cannot be customized.

    + +

    Keyboard layouts

    +

    Letters are recorded by the character they type, so they follow your layout (on AZERTY, the A key records a). With Shift, digit keys record the digit itself (Shift0, not the shifted symbol), on every layout and in every browser.

    +

    Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so the same key works on AZERTY, QWERTZ and other layouts — but only in browsers that expose the keyboard layout (Chromium). There, Shift with the + key records Shift+. In browsers without that support, such as Firefox or Safari, punctuation falls back to the typed character (Shift* instead): those shortcuts still work in the browser where you recorded them, but Shift combinations with punctuation may stop working if you switch browsers. If that affects you, record those shortcuts in a Chromium browser.

    +

    Two known limitations: number keys set opacity (0–9) only where digits are typed without Shift, so AZERTY layouts need the numeric keypad for them; and \ needs AltGr on some layouts (Spanish), where it is recorded with Alt and its exact form depends on the browser.

    + +

    Reset to default

    +

    When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.

    + +

    Export and import your shortcuts

    +

    Use the Import/Export button at the bottom of the page to move your setup to another account or share it with your team.

    +
      +
    • Export downloads a JSON file with your custom and disabled shortcuts. It is available once you have changed at least one shortcut.
    • +
    • Import reads a JSON file exported from Penpot and shows the shortcuts it will change. Click Import to confirm.
    • +
    +

    Importing replaces your custom shortcuts in each group included in the file (Workspace, Dashboard or Viewer). Custom shortcuts in those groups that are not in the file go back to their defaults. If an imported shortcut uses keys taken by a default one, the default one is disabled.

    Workspace

    The Workspace is where the designs are actually created. More about the Workspace.

    @@ -569,6 +607,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Path editor

    +

    Copy, cut, paste, duplicate and select all also work on nodes while you edit a path. For mouse gestures, see Reshape with the mouse.

    @@ -584,12 +623,17 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - - - + + + - + + + + + + @@ -614,7 +658,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - + @@ -628,6 +672,16 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface + + + + + + + + + +
    ⇧+
    Delete nodeSupr or Delete⌫Delete node, segment or handleDelete or Backspace⌦ or ⌫
    Draw PathDelete node and segmentsShiftDelete⇧⌫
    Draw nodes P P
    ⌘J
    Move NodesMove nodes M M
    Ctrl' ⌘'
    Flip horizontally (selected nodes, or the whole path)ShiftH⇧H
    Flip vertically (selected nodes, or the whole path)ShiftV⇧V
    diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index c3893f90e8..829e0e99ba 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -10,7 +10,8 @@ desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Be

    WebGL rendering (Beta)

    Since Penpot 2.16, optional WebGL rendering (Beta) is available on design.penpot.app and self-hosted instances. It is off by default.

    Turn it on from Your account → Settings or Workspace → Main menu → Preferences when you want a faster canvas for complex files.

    -

    In this beta, WebGL applies to the design workspace canvas only. View mode and exports still use the legacy renderer, so some visual differences are expected. If the beta gets in your way, you can switch back to the legacy renderer at any time.

    +

    With WebGL rendering enabled, the workspace canvas, View mode and exports (PNG, JPEG, WEBP, SVG and PDF) use the same engine, so exported files match what you see on the canvas. Text is edited with the new text editor, which also adds a right-click menu with Cut, Copy, Paste and Select all.

    +

    Some features need WebGL rendering, such as stroke per side, background blur and stroke to path.

    For what is in scope today, what we are prioritizing next, and known visual differences, read What's next for the Penpot WebGL renderer on the Penpot Community.

    diff --git a/exporter/README.md b/exporter/README.md index df4d4412db..5e3986b90c 100644 --- a/exporter/README.md +++ b/exporter/README.md @@ -88,6 +88,7 @@ section, with a `?job-id=` filter. | `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` | `4` | Admission limit | | `PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE`| `2` | Per-profile admission limit | | `PENPOT_EXPORTER_QUEUE_MAX` | `64` | Queue cap; over it, `429 :queue-full` | +| `PENPOT_EXPORTER_REPLICA_ID` | hostname | Names this replica; boot cleanup only cancels its own jobs | | `PENPOT_WASM_WORKER_POOL_MAX` | `2` | Headless render worker threads; min 1 | | `PENPOT_WASM_WORKER_POOL_MIN` | `1` | Workers kept warm; clamped to the max | | `PENPOT_WASM_WORKER_IDLE_TIMEOUT` | `300` | Silence before a worker is terminated, in seconds | diff --git a/exporter/package.json b/exporter/package.json index 745c197685..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 165475467d..843416db40 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/src/app/config.cljs b/exporter/src/app/config.cljs index 0fcc5a052d..4f94887ce0 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -9,6 +9,7 @@ (:require ["node:buffer" :as buffer] ["node:crypto" :as crypto] + ["node:os" :as os] ["node:process" :as process] [app.common.data :as d] [app.common.flags :as flags] @@ -55,6 +56,7 @@ [:exporter-max-jobs-per-profile {:optional true} ::sm/int] [:exporter-queue-max {:optional true} ::sm/int] [:exporter-job-ttl {:optional true} ::sm/int] + [:exporter-replica-id {:optional true} :string] [:wasm-worker-pool-max {:optional true} ::sm/int] [:wasm-worker-pool-min {:optional true} ::sm/int] [:wasm-worker-idle-timeout {:optional true} ::sm/int] @@ -124,6 +126,12 @@ (or (c/get config :internal-uri) (c/get config :public-uri))) +(def replica-id + "Names this exporter among its siblings. It must survive a restart, since + the boot sweep uses it to find the jobs the previous run left behind." + (or (c/get config :exporter-replica-id) + (os/hostname))) + (def management-key (let [key (or (c/get config :exporter-shared-key) (let [secret-key (c/get config :secret-key) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index a1ddab7efa..7dcf3a5b71 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -33,6 +33,7 @@ (l/info :msg "initializing" :public-uri (str (cf/get :public-uri)) :internal-uri (str (cf/get-internal-uri)) + :replica cf/replica-id :version (:full cf/version)) (l/info :msg "headless wasm export available" :wasm-dir wasm/artifact-dir @@ -86,8 +87,19 @@ (fn [cause] (js/console.error cause))) +(defonce ^:private exiting? (atom false)) + +(defn- exit! + "Cancels this process's jobs before exiting, so their records and clients + do not keep waiting on work that dies with it. A second signal exits at once." + [] + (if (compare-and-set! exiting? false true) + (->> (shutdown-step "cancel-jobs" jobs/cancel-all!) + (p/fnly (fn [_ _] (proc/exit 0)))) + (proc/exit 0))) + ;; Signals are only delivered to the main thread, and `exit` in a worker would ;; take down that worker rather than the process. (when ^boolean wt/isMainThread - (.on proc/default "SIGTERM" (fn [] (proc/exit 0))) - (.on proc/default "SIGINT" (fn [] (proc/exit 0)))) + (.on proc/default "SIGTERM" exit!) + (.on proc/default "SIGINT" exit!)) diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs index e3497b2861..d555269a52 100644 --- a/exporter/src/app/jobs.cljs +++ b/exporter/src/app/jobs.cljs @@ -20,6 +20,7 @@ [app.common.logging :as l] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.jobs.store :as store] [app.redis :as redis] [promesa.core :as p])) @@ -81,6 +82,7 @@ [{:keys [profile-id cmd backend total name resource-id]} run-fn] (let [job {:id (uuid/next) :profile-id profile-id + :replica cf/replica-id :cmd cmd :backend backend :state "queued" @@ -165,7 +167,10 @@ overwrite either." [job data] (if-let [job (live job)] - (persist-and-publish! (merge job data)) + (let [job (merge job data)] + (l/info :hint "export job" :job-id (str (:id job)) :state (:state job) + :replica (:replica job)) + (persist-and-publish! job)) (p/resolved job))) (defn start! @@ -208,7 +213,7 @@ :error (ex-message cause)})) (defn- cancel-local! - [job-id] + [job-id & {:as data}] (let [k (str job-id) rt (get @registry k) job (:job rt)] @@ -222,7 +227,7 @@ ;; (`scheduler/drop-queued!`) releases the job, and `transition!` on a ;; released job is a no-op, so a queued job would keep claiming to be ;; queued in the store and never publish its `cancelled` update. - (let [result (transition! job {:state "cancelled" :ended-at (now-ms)})] + (let [result (transition! job (merge {:state "cancelled" :ended-at (now-ms)} data))] (doseq [f (:cancel-fns rt)] (try (f) @@ -245,22 +250,40 @@ (store/request-cancel! (:id job)) job))))))) +(defn cancel-all! + "Cancels every job this process holds, on shutdown. Nothing in flight survives + the process, and clients learn it now rather than when this replica boots + again, if it ever does under the same id." + [] + (let [job-ids (keys @registry)] + (when (seq job-ids) + (l/info :hint "cancelling jobs on shutdown" :count (count job-ids))) + (->> job-ids + (map #(cancel-local! % :interrupted true)) + (p/all)))) + +(defn- abandoned? + "A job this replica's previous run left mid-flight. A sibling's jobs are its + own business: they may well be running." + [job] + (and (= cf/replica-id (:replica job)) + (not (terminal? job)))) + (defn- clean-abandoned! - "Marks every job left mid-flight by a previous process as cancelled. + "Marks every job left mid-flight by a previous run of this replica as + cancelled. A queue and its running jobs live in the memory of the process that owns them, so nothing in flight when it died can be resumed; without this the - record would keep claiming to be running until its TTL expires. - - NOTE: the store cannot tell whose jobs are whose, so with more than one - exporter behind a load balancer this would also cancel a sibling's running - jobs. Single-instance deployments only." + record would keep claiming to be running until its TTL expires. A replica + that never comes back under the same id leaves its records to the TTL." [] (->> (store/fetch-all) (p/mcat (fn [jobs] - (let [abandoned (remove terminal? jobs)] + (let [abandoned (filter abandoned? jobs)] (when (seq abandoned) (l/warn :hint "cancelling jobs abandoned by a previous process" + :replica cf/replica-id :count (count abandoned))) (->> abandoned (map (fn [job] diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs index 320689d90d..a986019a57 100644 --- a/exporter/src/app/jobs/scheduler.cljs +++ b/exporter/src/app/jobs/scheduler.cljs @@ -8,6 +8,7 @@ "Admission control for export jobs. Limits concurrent jobs and rejects work rather than allowing an unbounded backlog. + Every limit is per replica: the queue lives in this process only. Queue order is FIFO, except jobs whose profile is already at its cap are skipped, as are headless jobs once every render worker is busy." (:require diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index 0160a89a4a..88ae874009 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -287,11 +287,10 @@ (defn render-shape-pdf "Renders the shape subtree to PDF bytes (Uint8Array)." - [shape-id scale] + [shape-id] (let [buf (uuid/get-u32 shape-id)] (-> (h/call wasm/internal-module "_render_shape_pdf" - (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) - scale) + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)) (read-render-result LEN-HEADER-BYTES)))) (defn render-shape-svg diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index 77e53a9e6b..7889a6e3c5 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -418,7 +418,7 @@ (defn- render-object-bytes [type id scale] (case type - :pdf (let [bytes (wasm/render-shape-pdf id scale)] + :pdf (let [bytes (wasm/render-shape-pdf id)] (l/dbg :hint "PDF generated via Skia (render-wasm headless)" :object-id (str id) :backend "skia-wasm" diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs index c8baa9b9fd..a92c1c1790 100644 --- a/exporter/test/exporter_tests/jobs_test.cljs +++ b/exporter/test/exporter_tests/jobs_test.cljs @@ -9,6 +9,7 @@ reported and swallowed, so only the in-process record is exercised." (:require [app.common.uuid :as uuid] + [app.config :as cf] [app.jobs :as jobs] [cljs.test :as t :include-macros true] [promesa.core :as p])) @@ -84,3 +85,23 @@ (t/is (= "ended" (:state ended))) (t/is (nil? (jobs/lookup (:id job)))) (done))))) + +(t/deftest boot-sweep-leaves-sibling-jobs-alone + (let [abandoned? #'jobs/abandoned?] + (t/is (abandoned? {:replica cf/replica-id :state "running"})) + (t/is (abandoned? {:replica cf/replica-id :state "queued"})) + (t/is (not (abandoned? {:replica cf/replica-id :state "ended"}))) + (t/is (not (abandoned? {:replica (str cf/replica-id "-sibling") :state "running"}))))) + +(t/deftest shutdown-cancels-all-its-jobs + (t/async done + (p/let [running (create!) + queued (create!) + _ (jobs/start! running) + _ (jobs/cancel-all!)] + (doseq [job [running queued]] + (let [current (jobs/lookup (:id job))] + (t/is (= "cancelled" (:state current))) + (t/is (true? (:interrupted current)))) + (jobs/release! (:id job))) + (done)))) diff --git a/frontend/deps.edn b/frontend/deps.edn index b1293a652c..7e19542857 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -15,8 +15,8 @@ :git/url "https://github.com/funcool/tubax.git"} funcool/potok2 - {:git/tag "v2.2" - :git/sha "0f7e15a" + {:git/tag "v2.3" + :git/sha "bdebbdb" :git/url "https://github.com/funcool/potok.git" :exclusions [funcool/beicon2]} @@ -26,10 +26,15 @@ :git/url "https://github.com/funcool/beicon.git"} funcool/rumext - {:git/tag "v2.25" - :git/sha "27e5a1a" + {:git/tag "v2.26" + :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 0080512b22..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "browserslist": [ "defaults" ], @@ -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/draft-js/package.json b/frontend/packages/draft-js/package.json index be8840e69d..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/README.md b/frontend/packages/mousetrap/README.md new file mode 100644 index 0000000000..848c8d8bfc --- /dev/null +++ b/frontend/packages/mousetrap/README.md @@ -0,0 +1,19 @@ +# @penpot/mousetrap + +Vendored keyboard-shortcut library (based on Mousetrap 1.6.5), migrated +from `code`/`keyCode` to `event.key` with lowercase canonical tokens so +shortcuts behave the same across keyboard layouts. + +## Tests + +Unit suite with the Node standard runner, no browser and no extra +dependencies: + +```bash +pnpm test # node --test "test/*.test.js" +``` + +The suite builds real `Mousetrap` instances over fake targets and +synthetic events, with fake timers for sequences. It runs in the frontend +CI through `frontend/scripts/test` and must stay deterministic (no +network, no real time, no platform reads). diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 4558a758b0..e3864396a4 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -21,233 +21,103 @@ */ const globalDocument = globalThis?.document; /** - * mapping of special keycodes to their corresponding keys + * Canonical key names, all lowercase. * - * everything in this dictionary cannot use keypress events - * so it has to be here to map to the correct keycodes for - * keyup/keydown events + * `event.key` (final character for the active layout) is the source of + * truth. Single printable characters are lowercased, `" "` becomes + * `"space"`, and named keys map through this table.modifiers pressed + * alone (`Shift`, `Control`, ...) normalize to `shift`, `ctrl`, `alt`, + * `meta` so bind-time and event-time tokens always match. * * @type {Object} */ -var _MAP = { - 8: "backspace", - 9: "tab", - 13: "enter", - 16: "shift", - 17: "ctrl", - 18: "alt", - 20: "capslock", - 27: "esc", - 32: "space", - 33: "pageup", - 34: "pagedown", - 35: "end", - 36: "home", - 37: "left", - 38: "up", - 39: "right", - 40: "down", - 45: "ins", - 46: "del", - 91: "meta", - 93: "meta", - 224: "meta", - 219: "219", +const _KEY_NAMES = { + " ": "space", + Spacebar: "space", + ArrowUp: "up", + Up: "up", + ArrowDown: "down", + Down: "down", + ArrowLeft: "left", + Left: "left", + ArrowRight: "right", + Right: "right", + Escape: "escape", + Esc: "escape", + Enter: "enter", + Backspace: "backspace", + Delete: "del", + Del: "del", + Insert: "ins", + Ins: "ins", + Tab: "tab", + Home: "home", + End: "end", + PageUp: "pageup", + PageDown: "pagedown", + CapsLock: "capslock", + Shift: "shift", + Control: "ctrl", + Alt: "alt", + Meta: "meta", }; /** - * mapping for special characters so they can support - * - * this dictionary is only used incase you want to bind a - * keyup or keydown event to one of these keys + * Aliases accepted in bind strings, all resolving to lowercase canonical + * tokens. `mod` follows the platform (Meta on Apple, Ctrl elsewhere). * * @type {Object} */ -var _KEYCODE_MAP = { - 106: "*", - 107: "+", - 109: "-", - 110: ".", - 111: "/", - 186: ";", - 187: "=", - 188: ",", - 189: "-", - 190: ".", - 191: "/", - 192: "`", - 219: "[", - 220: "\\", - 221: "]", - 222: "'", -}; +const globalNavigator = globalThis.navigator; -/** - * this is a mapping of keys that require shift on a US keypad - * back to the non shift equivelents - * - * this is so you can use keyup events with these keys - * - * note that this will only work reliably on US keyboards - * - * @type {Object} - */ -var _SHIFT_MAP = { - "~": "`", - "!": "1", - "@": "2", - "#": "3", - $: "4", - "%": "5", - "^": "6", - "&": "7", - "*": "8", - "(": "9", - ")": "0", - _: "-", - "+": "=", - ":": ";", - '\"': "'", - "<": ",", - ">": ".", - "?": "/", - "|": "\\", -}; - -/** - * this is a list of special strings you can use to map - * to modifier keys when you specify your keyboard shortcuts - * - * @type {Object} - */ - -var globalNavigator = globalThis.navigator; - -var _SPECIAL_ALIASES = { +const _SPECIAL_ALIASES = { + ctrl: "ctrl", + control: "ctrl", + alt: "alt", option: "alt", + shift: "shift", + meta: "meta", command: "meta", - return: "enter", - escape: "esc", - plus: "+", mod: /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? "meta" : "ctrl", + return: "enter", + enter: "enter", + esc: "escape", + escape: "escape", + del: "del", + delete: "del", + ins: "ins", + insert: "ins", + space: "space", + plus: "+", }; /** - * variable to store the flipped version of _MAP from above - * needed to check if we should use keypress or not when no action - * is specified + * Physical codes treated positionally. Letters and digits resolve by + * glyph (`event.key`), but punctuation resolves by position (`event.code`) + * through the layout map, so the same physical key matches on every + * layout with and without `Shift`. Without a map the typed glyph applies. * - * @type {Object|undefined} + * @type {Set} */ -var _REVERSE_MAP; +const _PUNCTUATION_CODES = new Set([ + "Minus", + "Equal", + "BracketLeft", + "BracketRight", + "Backslash", + "Semicolon", + "Quote", + "Backquote", + "Comma", + "Period", + "Slash", +]); /** - * loop through the f keys, f1 to f19 and add them to the map - * programatically + * Resolves an event to its canonical key. Implemented as + * `Mousetrap#_characterFromEvent` so punctuation can consult the + * instance layout map (see `setLayoutMap`). */ -for (var i = 1; i < 20; ++i) { - _MAP[111 + i] = "f" + i; -} - -/** - * loop through to map numbers on the numeric keypad - */ -for (i = 0; i <= 9; ++i) { - // This needs to use a string cause otherwise since 0 is falsey - // mousetrap will never fire for numpad 0 pressed as part of a keydown - // event. - // - // @see https://github.com/ccampbell/mousetrap/pull/258 - _MAP[i + 96] = i.toString(); -} - -/** - * cross browser add event method - * - * @param {Element|HTMLDocument} object - * @param {string} type - * @param {Function} callback - * @returns void - */ -function _addEvent(object, type, callback) { - if (object.addEventListener) { - object.addEventListener(type, callback, false); - return; - } - - object.attachEvent("on" + type, callback); -} - -/** - * true for the letter (A-Z) and main-row digit (0-9) keycodes - * - * @param {number} which - * @return {boolean} - */ -function _isLetterOrDigit(which) { - return (which >= 48 && which <= 57) || (which >= 65 && which <= 90); -} - -/** - * takes the event and returns the key character - * - * @param {Event} e - * @return {string} - */ -function _characterFromEvent(e) { - // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. - if (e.code && e.code.indexOf("Numpad") === 0) { - var suffix = e.code.substring(6); - if (suffix.length === 1 && suffix >= "0" && suffix <= "9") { - return "num" + suffix; - } - } - - // for keypress events we should return the character as is - if (e.type == "keypress") { - var character = String.fromCharCode(e.which); - - // if the shift key is not pressed then it is safe to assume - // that we want the character to be lowercase. this means if - // you accidentally have caps lock on then your key bindings - // will continue to work - // - // the only side effect that might not be desired is if you - // bind something like 'A' cause you want to trigger an - // event when capital A is pressed caps lock will no longer - // trigger the event. shift+a will though. - if (!e.shiftKey) { - character = character.toLowerCase(); - } - - return character; - } - - // for non keypress events the special maps are needed - if (_MAP[e.which]) { - return _MAP[e.which]; - } - - if (_KEYCODE_MAP[e.which]) { - return _KEYCODE_MAP[e.which]; - } - - // keydown bindings name the key, not the glyph it types under modifiers - // ("fi" for Option+Shift+5, ")" for Shift+0) - if (_isLetterOrDigit(e.which)) { - return String.fromCharCode(e.which).toLowerCase(); - } - - // if it is not in the special map - if (typeof e.key === "string") { - return e.key.toLowerCase(); - } - - // with keydown and keyup events the character seems to always - // come in as an uppercase character whether you are pressing shift - // or not. we should make sure it is always lowercase for comparisons - return String.fromCharCode(e.which).toLowerCase(); -} /** * checks if two arrays are equal @@ -263,11 +133,18 @@ function _modifiersMatch(modifiers1, modifiers2) { /** * takes a key event and figures out what the modifiers are * + * On Windows `AltGr` reports `ctrlKey + altKey` as well. When the + * `AltGraph` modifier is active the phantom `ctrl` is dropped so typing + * (e.g. `]` on Spanish keyboards) does not fire `ctrl+alt` shortcuts. + * * @param {Event} e * @returns {Array} */ function _eventModifiers(e) { - var modifiers = []; + const modifiers = []; + const altGraph = + typeof e.getModifierState === "function" && + e.getModifierState("AltGraph"); if (e.shiftKey) { modifiers.push("shift"); @@ -277,7 +154,7 @@ function _eventModifiers(e) { modifiers.push("alt"); } - if (e.ctrlKey) { + if (e.ctrlKey && !altGraph) { modifiers.push("ctrl"); } @@ -289,92 +166,15 @@ function _eventModifiers(e) { } /** - * prevents default for this event + * determines if the key is a modifier key or not * - * @param {Event} e - * @returns void - */ -function _preventDefault(e) { - if (e.preventDefault) { - e.preventDefault(); - return; - } - - e.returnValue = false; -} - -/** - * stops propogation for this event - * - * @param {Event} e - * @returns void - */ -function _stopPropagation(e) { - if (e.stopPropagation) { - e.stopPropagation(); - return; - } - - e.cancelBubble = true; -} - -/** - * determines if the keycode specified is a modifier key or not - * - * @param {string} key + * @param {string} key canonical lowercase key * @returns {boolean} */ function _isModifier(key) { return key == "shift" || key == "ctrl" || key == "alt" || key == "meta"; } -/** - * reverses the map lookup so that we can look for specific keys - * to see what can and can't use keypress - * - * @return {Object} - */ -function _getReverseMap() { - if (!_REVERSE_MAP) { - _REVERSE_MAP = {}; - for (var key in _MAP) { - // pull out the numeric keypad from here cause keypress should - // be able to detect the keys from the character - if (key > 95 && key < 112) { - continue; - } - - if (_MAP.hasOwnProperty(key)) { - _REVERSE_MAP[_MAP[key]] = key; - } - } - } - return _REVERSE_MAP; -} - -/** - * picks the best action based on the key combination - * - * @param {string} key - character for key - * @param {Array} modifiers - * @param {string=} action passed in - */ -function _pickBestAction(key, modifiers, action) { - // if no action was picked in we should try to pick the one - // that we think would work best for this key - if (!action) { - action = _getReverseMap()[key] ? "keydown" : "keypress"; - } - - // modifier keys don't work as expected with keypress, - // switch to keydown - if (action == "keypress" && modifiers.length) { - action = "keydown"; - } - - return action; -} - /** * Converts from a string key combination to an array * @@ -398,29 +198,24 @@ function _keysFromString(combination) { * @returns {Object} */ function _getKeyInfo(combination, action) { - var keys; - var key; - var i; - var modifiers = []; + let keys; + let key; + const modifiers = []; // take the keys from this pattern and figure out what the actual // pattern is all about keys = _keysFromString(combination); - for (i = 0; i < keys.length; ++i) { + for (let i = 0; i < keys.length; ++i) { key = keys[i]; - // normalize key names + // normalize key names to lowercase canonical tokens if (_SPECIAL_ALIASES[key]) { key = _SPECIAL_ALIASES[key]; - } - - // if this is not a keypress event then we should - // be smart about using shift keys - // this will only work for US keyboards however - if (action && action != "keypress" && _SHIFT_MAP[key]) { - key = _SHIFT_MAP[key]; - modifiers.push("shift"); + } else if (key.length === 1) { + key = key.toLowerCase(); + } else { + key = (_KEY_NAMES[key] || key).toLowerCase(); } // if this key is a modifier then add it to the list of modifiers @@ -429,9 +224,10 @@ function _getKeyInfo(combination, action) { } } - // depending on what the key combination is - // we will try to pick the best event for it - action = _pickBestAction(key, modifiers, action); + // everything runs on keydown unless the caller asked for keyup + if (!action) { + action = "keydown"; + } return { key: key, @@ -440,240 +236,297 @@ function _getKeyInfo(combination, action) { }; } -function _belongsTo(element, ancestor) { - if (element === null || element === globalDocument) { - return false; - } +class Mousetrap { + constructor(targetElement = globalDocument) { + /** + * a list of all the callbacks setup via Mousetrap.bind() + * + * @type {Object} + */ + this._callbacks = {}; - if (element === ancestor) { - return true; - } + /** + * direct map of string combinations to callbacks used for trigger() + * + * @type {Object} + */ + this._directMap = {}; - return _belongsTo(element.parentNode, ancestor); -} + /** + * keeps track of what level each sequence is at since multiple + * sequences can start out with the same sequence + * + * @type {Object} + */ + this._sequenceLevels = {}; -function Mousetrap(targetElement) { - var self = this; + /** + * variable to store the setTimeout call + * + * @type {null|number} + */ + this._resetTimer = undefined; - targetElement = targetElement || globalDocument; + /** + * temporary state where we will ignore the next keyup + * + * @type {boolean|string} + */ + this._ignoreNextKeyup = false; - /** - * element to attach key events to - * - * @type {Element} - */ - self.target = targetElement; + /** + * are we currently inside of a sequence? + * type of action ("keyup" or "keydown") or false + * + * @type {boolean|string} + */ + this._nextExpectedAction = false; - /** - * a list of all the callbacks setup via Mousetrap.bind() - * - * @type {Object} - */ - self._callbacks = {}; + /** + * layout map for positional punctuation resolution, as returned by + * `navigator.keyboard.getLayoutMap()` (an object with `.get(code)`). + * `null` means glyph fallback. Set via `setLayoutMap`. + * + * @type {Object|null} + */ + this._layoutMap = null; - /** - * direct map of string combinations to callbacks used for trigger() - * - * @type {Object} - */ - self._directMap = {}; - - /** - * keeps track of what level each sequence is at since multiple - * sequences can start out with the same sequence - * - * @type {Object} - */ - var _sequenceLevels = {}; - - /** - * variable to store the setTimeout call - * - * @type {null|number} - */ - var _resetTimer; - - /** - * temporary state where we will ignore the next keyup - * - * @type {boolean|string} - */ - var _ignoreNextKeyup = false; - - /** - * temporary state where we will ignore the next keypress - * - * @type {boolean} - */ - var _ignoreNextKeypress = false; - - /** - * are we currently inside of a sequence? - * type of action ("keyup" or "keydown" or "keypress") or false - * - * @type {boolean|string} - */ - var _nextExpectedAction = false; - - /** - * resets all sequence counters except for the ones passed in - * - * @param {Object} doNotReset - * @returns void - */ - function _resetSequences(doNotReset) { - doNotReset = doNotReset || {}; - - var activeSequences = false, - key; - - for (key in _sequenceLevels) { - if (doNotReset[key]) { - activeSequences = true; - continue; - } - _sequenceLevels[key] = 0; - } - - if (!activeSequences) { - _nextExpectedAction = false; + if (targetElement) { + targetElement.addEventListener("keydown", (e) => this._handleKeyEvent(e)); + targetElement.addEventListener("keyup", (e) => this._handleKeyEvent(e)); } } /** - * finds all callbacks that match based on the keycode, modifiers, - * and action + * binds an event to mousetrap * - * @param {string} character - * @param {Array} modifiers - * @param {Event|Object} e - * @param {string=} sequenceName - name of the sequence we are looking for - * @param {string=} combination - * @param {number=} level - * @returns {Array} - */ - function _getMatches( - character, - modifiers, - e, - sequenceName, - combination, - level, - ) { - var i; - var callback; - var matches = []; - var action = e.type; - - // if there are no events related to this keycode - if (!self._callbacks[character]) { - return []; - } - - // if a modifier key is coming up on its own we should allow it - if (action == "keyup" && _isModifier(character)) { - modifiers = [character]; - } - - // loop through all callbacks for the key that was pressed - // and see if any of them match - for (i = 0; i < self._callbacks[character].length; ++i) { - callback = self._callbacks[character][i]; - - // if a sequence name is not specified, but this is a sequence at - // the wrong level then move onto the next match - if ( - !sequenceName && - callback.seq && - _sequenceLevels[callback.seq] != callback.level - ) { - continue; - } - - // if the action we are looking for doesn't match the action we got - // then we should keep going - if (action != callback.action) { - continue; - } - - // if this is a keypress event and the meta key and control key - // are not pressed that means that we need to only look at the - // character, otherwise check the modifiers as well - // - // chrome will not fire a keypress if meta or control is down - // safari will fire a keypress if meta or meta+shift is down - // firefox will fire a keypress if meta or control is down - if ( - (action == "keypress" && !e.metaKey && !e.ctrlKey) || - _modifiersMatch(modifiers, callback.modifiers) - ) { - // when you bind a combination or sequence a second time it - // should overwrite the first one. if a sequenceName or - // combination is specified in this call it does just that - // - // @todo make deleting its own method? - var deleteCombo = !sequenceName && callback.combo == combination; - var deleteSequence = - sequenceName && - callback.seq == sequenceName && - callback.level == level; - if (deleteCombo || deleteSequence) { - self._callbacks[character].splice(i, 1); - } - - matches.push(callback); - } - } - - return matches; - } - - /** - * actually calls the callback function + * can be a single key, a combination of keys separated with +, + * an array of keys, or a sequence of keys separated by spaces * - * if your callback function returns false this will use the jquery - * convention - prevent default and stop propogation on the event + * be sure to list the modifier keys first to make sure that the + * correct key ends up getting bound (the last key in the pattern) * + * @param {string|Array} keys * @param {Function} callback - * @param {Event} e + * @param {string=} action - 'keydown' or 'keyup' (defaults to 'keydown') * @returns void */ - function _fireCallback(callback, e, combo, sequence) { - // if this event should not happen stop here - if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) { - return; + bind(keys, callback, action, overwrite) { + keys = keys instanceof Array ? keys : [keys]; + this._bindMultiple(keys, callback, action, overwrite); + return this; + } + + /** + * unbinds an event to mousetrap + * + * the unbinding sets the callback function of the specified key combo + * to an empty function and deletes the corresponding key in the + * _directMap dict. + * + * TODO: actually remove this from the _callbacks dictionary instead + * of binding an empty function + * + * the keycombo+action has to be exactly the same as + * it was defined in the bind method + * + * @param {string|Array} keys + * @param {string} action + * @returns void + */ + unbind(keys, action) { + return this.bind(keys, function () {}, action); + } + + /** + * triggers an event that has already been bound + * + * @param {string} keys + * @param {string=} action + * @returns void + */ + trigger(keys, action) { + if (this._directMap[keys + ":" + action]) { + this._directMap[keys + ":" + action]({}, keys); + } + return this; + } + + /** + * resets the library back to its initial state. this is useful + * if you want to clear out the current keyboard shortcuts and bind + * new ones - for example if you switch to another page + * + * @returns void + */ + reset() { + this._callbacks = {}; + this._directMap = {}; + return this; + } + + /** + * sets the keyboard layout map used for positional punctuation + * resolution (the same map the shortcut recorder uses). + * + * Pass the `KeyboardLayoutMap` from + * `navigator.keyboard.getLayoutMap()`, or `null` to go back to the + * glyph fallback. Read live at dispatch time, so call it whenever + * the map resolves without rebinding. + * + * @param {Object|null} map - object with `.get(code)`, or null + * @returns void + */ + setLayoutMap(map) { + this._layoutMap = map; + return this; + } + + /** + * takes the event and returns the canonical key + * + * Letters, digits and named keys resolve by glyph: single characters + * lowercase so `Shift+Z` matches a `ctrl+shift+z` bind, `" "` maps to + * `space`, and named keys map through `_KEY_NAMES`. The exception is a + * digit row key held with `Shift`, which resolves to the digit itself + * (`Digit0` types ")" on US layouts but "0" on AZERTY, so `shift+0` + * must mean the physical digit key everywhere). Punctuation + * resolves positionally via the layout map (see `setLayoutMap`), + * falling back to the glyph when no map is set. Numpad digits stay + * positional (`num0`..`num9`) so they keep their own bindings. + * + * @param {Event} e + * @return {string|null} + */ + _characterFromEvent(e) { + // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. + if (e.code && e.code.indexOf("Numpad") === 0) { + const suffix = e.code.substring(6); + if (suffix.length === 1 && suffix >= "0" && suffix <= "9") { + return "num" + suffix; + } } - if (callback(e, combo) === false) { - _preventDefault(e); - _stopPropagation(e); + // Punctuation is positional: with a layout map the primary + // (unshifted) character of the physical key wins, so `Shift+*` on a + // Spanish layout (code `BracketRight`) resolves to `+` like the + // recorder stores. Without a map the typed glyph applies. + if (e.code && _PUNCTUATION_CODES.has(e.code) && this._layoutMap) { + let mapped = null; + try { + mapped = this._layoutMap.get(e.code); + } catch { + mapped = null; + } + if (typeof mapped === "string" && mapped !== "") { + return mapped.toLowerCase(); + } } + + const key = e.key; + if (typeof key !== "string" || key === "") { + return null; + } + + // Shifted digits resolve to the digit itself: with `Shift` held, + // `Digit0` types ")" on US layouts but "0" on AZERTY, so the glyph + // can't be the token. `shift+0` means the physical digit key + // everywhere, like `shift+z` means `z` regardless of `Shift`. + if (e.shiftKey && e.code && /^Digit[0-9]$/.test(e.code) && key.length === 1) { + return e.code.charAt(5); + } + + if (_KEY_NAMES[key]) { + return _KEY_NAMES[key]; + } + + if (key.length === 1) { + return key.toLowerCase(); + } + + if (/^F\d{1,2}$/.test(key)) { + return key.toLowerCase(); + } + + if (key === "Dead") { + return null; + } + + return key.toLowerCase(); + } + + /** + * should we stop this event before firing off callbacks + * + * @param {Event} e + * @param {Element} element + * @return {boolean} + */ + stopCallback(e, element, combo) { + // if the element has the data attribute "mousetrap-dont-stop" then no need + // to stop. It should be used like
    ...
    + // or :div {:data-mousetrap-dont-stop true} + if ("mousetrapDontStop" in element.dataset) { + return false; + } + + if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { + return false; + } + + // Keyup events need to be dispatched always. Otherwise some events can be stuck + if (e.type == "keyup") { + return false; + } + + if ("composedPath" in e && typeof e.composedPath === "function") { + // For open shadow trees, update `element` so that the following check works. + const initialEventTarget = e.composedPath()[0]; + if (initialEventTarget !== e.target) { + element = initialEventTarget; + } + } + + // stop for input, select, textarea and button + const shouldStop = + element.tagName == "INPUT" || + element.tagName == "SELECT" || + element.tagName == "TEXTAREA" || + (element.tagName == "BUTTON" && + typeof combo === "string" && + combo.includes("tab")) || + (element.contentEditable && + (element.contentEditable == "true" || + element.contentEditable === "plaintext-only")); + + return shouldStop; } /** * handles a character key event * + * exposed publicly so it can be overwritten by extensions + * * @param {string} character * @param {Array} modifiers * @param {Event} e * @returns void */ - self._handleKey = function (character, modifiers, e) { - var callbacks = _getMatches(character, modifiers, e); - var i; - var doNotReset = {}; - var maxLevel = 0; - var processedSequenceCallback = false; + handleKey(character, modifiers, e) { + const callbacks = this._getMatches(character, modifiers, e); + const doNotReset = {}; + let maxLevel = 0; + let processedSequenceCallback = false; // Calculate the maxLevel for sequences so we can only execute the longest callback sequence - for (i = 0; i < callbacks.length; ++i) { + for (let i = 0; i < callbacks.length; ++i) { if (callbacks[i].seq) { maxLevel = Math.max(maxLevel, callbacks[i].level); } } // loop through matching callbacks for this key event - for (i = 0; i < callbacks.length; ++i) { + for (let i = 0; i < callbacks.length; ++i) { // fire for all sequence callbacks // this is because if for example you have multiple sequences // bound such as "g i" and "g t" they both need to fire the @@ -696,7 +549,7 @@ function Mousetrap(targetElement) { // keep a list of which sequences were matches for later doNotReset[callbacks[i].seq] = 1; - _fireCallback( + this._fireCallback( callbacks[i].callback, e, callbacks[i].combo, @@ -708,12 +561,12 @@ function Mousetrap(targetElement) { // if there were no sequence matches but we are still here // that means this is a regular match so we should fire that if (!processedSequenceCallback) { - _fireCallback(callbacks[i].callback, e, callbacks[i].combo); + this._fireCallback(callbacks[i].callback, e, callbacks[i].combo); } } // if the key you pressed matches the type of sequence without - // being a modifier (ie "keyup" or "keypress") then we should + // being a modifier (ie "keyup" or "keydown") then we should // reset all sequences that were not matched by this event // // this is so, for example, if you have the sequence "h a t" and you @@ -723,136 +576,29 @@ function Mousetrap(targetElement) { // modifier keys are ignored because you can have a sequence // that contains modifiers such as "enter ctrl+space" and in most // cases the modifier key will be pressed before the next key - // - // also if you have a sequence such as "ctrl+b a" then pressing the - // "b" key will trigger a "keypress" and a "keydown" - // - // the "keydown" is expected when there is a modifier, but the - // "keypress" ends up matching the _nextExpectedAction since it occurs - // after and that causes the sequence to reset - // - // we ignore keypresses in a sequence that directly follow a keydown - // for the same character - var ignoreThisKeypress = e.type == "keypress" && _ignoreNextKeypress; - if ( - e.type == _nextExpectedAction && - !_isModifier(character) && - !ignoreThisKeypress - ) { - _resetSequences(doNotReset); + if (e.type == this._nextExpectedAction && !_isModifier(character)) { + this._resetSequences(doNotReset); } - - _ignoreNextKeypress = processedSequenceCallback && e.type == "keydown"; - }; - - /** - * handles a keydown event - * - * @param {Event} e - * @returns void - */ - function _handleKeyEvent(e) { - // normalize e.which for key events - // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion - if (typeof e.which !== "number") { - e.which = e.keyCode; - } - - var character = _characterFromEvent(e); - - // no character found then stop - if (!character) { - return; - } - - // need to use === for the character check because the character can be 0 - if (e.type == "keyup" && _ignoreNextKeyup === character) { - _ignoreNextKeyup = false; - return; - } - - self.handleKey(character, _eventModifiers(e), e); } /** - * called to set a 1 second timeout on the specified sequence + * binds multiple combinations to the same callback * - * this is so after each key press in the sequence you have 1 second - * to press the next key before you have to start over - * - * @returns void - */ - function _resetSequenceTimer() { - clearTimeout(_resetTimer); - _resetTimer = setTimeout(_resetSequences, 1000); - } - - /** - * binds a key sequence to an event - * - * @param {string} combo - combo specified in bind call - * @param {Array} keys + * @param {Array} combinations * @param {Function} callback - * @param {string=} action + * @param {string|undefined} action * @returns void */ - function _bindSequence(combo, keys, callback, action) { - // start off by adding a sequence level record for this combination - // and setting the level to 0 - _sequenceLevels[combo] = 0; - - /** - * callback to increase the sequence level for this sequence and reset - * all other sequences that were active - * - * @param {string} nextAction - * @returns {Function} - */ - function _increaseSequence(nextAction) { - return function () { - _nextExpectedAction = nextAction; - ++_sequenceLevels[combo]; - _resetSequenceTimer(); - }; - } - - /** - * wraps the specified callback inside of another function in order - * to reset all sequence counters as soon as this sequence is done - * - * @param {Event} e - * @returns void - */ - function _callbackAndReset(e) { - _fireCallback(callback, e, combo); - - // we should ignore the next key up if the action is key down - // or keypress. this is so if you finish a sequence and - // release the key the final key will not trigger a keyup - if (action !== "keyup") { - _ignoreNextKeyup = _characterFromEvent(e); - } - - // weird race condition if a sequence ends with the key - // another sequence begins with - setTimeout(_resetSequences, 10); - } - - // loop through keys one at a time and bind the appropriate callback - // function. for any key leading up to the final one it should - // increase the sequence. after the final, it should reset all sequences - // - // if an action is specified in the original bind call then that will - // be used throughout. otherwise we will pass the action that the - // next key in the sequence should match. this allows a sequence - // to mix and match keypress and keydown events depending on which - // ones are better suited to the key provided - for (var i = 0; i < keys.length; ++i) { - var isFinal = i + 1 === keys.length; - var wrappedCallback = isFinal - ? _callbackAndReset - : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); - _bindSingle(keys[i], wrappedCallback, action, combo, i); + _bindMultiple(combinations, callback, action, overwrite) { + for (let i = 0; i < combinations.length; ++i) { + this._bindSingle( + combinations[i], + callback, + action, + undefined, + undefined, + overwrite, + ); } } @@ -866,39 +612,31 @@ function Mousetrap(targetElement) { * @param {number=} level - what part of the sequence the command is * @returns void */ - function _bindSingle( - combination, - callback, - action, - sequenceName, - level, - overwrite, - ) { + _bindSingle(combination, callback, action, sequenceName, level, overwrite) { // store a direct mapped reference for use with Mousetrap.trigger - self._directMap[combination + ":" + action] = callback; + this._directMap[combination + ":" + action] = callback; // make sure multiple spaces in a row become a single space combination = combination.replace(/\s+/g, " "); - var sequence = combination.split(" "); - var info; + const sequence = combination.split(" "); // if this pattern is a sequence of keys then run through this method // to reprocess each pattern one key at a time if (sequence.length > 1) { - _bindSequence(combination, sequence, callback, action); + this._bindSequence(combination, sequence, callback, action); return; } - info = _getKeyInfo(combination, action); + const info = _getKeyInfo(combination, action); // make sure to initialize array if this is the first time // a callback is added for this key - self._callbacks[info.key] = self._callbacks[info.key] || []; + this._callbacks[info.key] = this._callbacks[info.key] || []; // remove an existing match if there is one if (overwrite) { - _getMatches( + this._getMatches( info.key, info.modifiers, { type: info.action }, @@ -914,7 +652,7 @@ function Mousetrap(targetElement) { // // this is important because the way these are processed expects // the sequence ones to come first - self._callbacks[info.key][sequenceName ? "unshift" : "push"]({ + this._callbacks[info.key][sequenceName ? "unshift" : "push"]({ callback: callback, modifiers: info.modifiers, action: info.action, @@ -925,168 +663,218 @@ function Mousetrap(targetElement) { } /** - * binds multiple combinations to the same callback + * binds a key sequence to an event * - * @param {Array} combinations + * @param {string} combo - combo specified in bind call + * @param {Array} keys * @param {Function} callback - * @param {string|undefined} action + * @param {string=} action * @returns void */ - self._bindMultiple = function (combinations, callback, action, overwrite) { - for (var i = 0; i < combinations.length; ++i) { - _bindSingle( - combinations[i], - callback, - action, - undefined, - undefined, - overwrite, - ); - } - }; + _bindSequence(combo, keys, callback, action) { + // start off by adding a sequence level record for this combination + // and setting the level to 0 + this._sequenceLevels[combo] = 0; - if (targetElement) { - _addEvent(targetElement, "keypress", _handleKeyEvent); - _addEvent(targetElement, "keydown", _handleKeyEvent); - _addEvent(targetElement, "keyup", _handleKeyEvent); - } -} + /** + * callback to increase the sequence level for this sequence and reset + * all other sequences that were active + * + * @param {string} nextAction + * @returns {Function} + */ + const _increaseSequence = (nextAction) => () => { + this._nextExpectedAction = nextAction; + ++this._sequenceLevels[combo]; + this._resetSequenceTimer(); + }; -/** - * binds an event to mousetrap - * - * can be a single key, a combination of keys separated with +, - * an array of keys, or a sequence of keys separated by spaces - * - * be sure to list the modifier keys first to make sure that the - * correct key ends up getting bound (the last key in the pattern) - * - * @param {string|Array} keys - * @param {Function} callback - * @param {string=} action - 'keypress', 'keydown', or 'keyup' - * @returns void - */ -Mousetrap.prototype.bind = function (keys, callback, action, overwrite) { - var self = this; - keys = keys instanceof Array ? keys : [keys]; - self._bindMultiple.call(self, keys, callback, action, overwrite); - return self; -}; + /** + * wraps the specified callback inside of another function in order + * to reset all sequence counters as soon as this sequence is done + * + * @param {Event} e + * @returns void + */ + const _callbackAndReset = (e) => { + this._fireCallback(callback, e, combo); -/** - * unbinds an event to mousetrap - * - * the unbinding sets the callback function of the specified key combo - * to an empty function and deletes the corresponding key in the - * _directMap dict. - * - * TODO: actually remove this from the _callbacks dictionary instead - * of binding an empty function - * - * the keycombo+action has to be exactly the same as - * it was defined in the bind method - * - * @param {string|Array} keys - * @param {string} action - * @returns void - */ -Mousetrap.prototype.unbind = function (keys, action) { - var self = this; - return self.bind.call(self, keys, function () {}, action); -}; + // we should ignore the next key up if the action is keydown. + // this is so if you finish a sequence and + // release the key the final key will not trigger a keyup + if (action !== "keyup") { + this._ignoreNextKeyup = this._characterFromEvent(e); + } -/** - * triggers an event that has already been bound - * - * @param {string} keys - * @param {string=} action - * @returns void - */ -Mousetrap.prototype.trigger = function (keys, action) { - var self = this; - if (self._directMap[keys + ":" + action]) { - self._directMap[keys + ":" + action]({}, keys); - } - return self; -}; + // weird race condition if a sequence ends with the key + // another sequence begins with + setTimeout(() => this._resetSequences(), 10); + }; -/** - * resets the library back to its initial state. this is useful - * if you want to clear out the current keyboard shortcuts and bind - * new ones - for example if you switch to another page - * - * @returns void - */ -Mousetrap.prototype.reset = function () { - var self = this; - self._callbacks = {}; - self._directMap = {}; - return self; -}; - -/** - * should we stop this event before firing off callbacks - * - * @param {Event} e - * @param {Element} element - * @return {boolean} - */ -Mousetrap.prototype.stopCallback = function (e, element, combo) { - // if the element has the data attribute "mousetrap-dont-stop" then no need - // to stop. It should be used like
    ...
    - // or :div {:data-mousetrap-dont-stop true} - if ("mousetrapDontStop" in element.dataset) { - return false; - } - - if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { - return false; - } - - // Keyup events need to be dispatched always. Otherwise some events can be stuck - if (e.type == "keyup") { - return false; - } - - if ("composedPath" in e && typeof e.composedPath === "function") { - // For open shadow trees, update `element` so that the following check works. - const initialEventTarget = e.composedPath()[0]; - if (initialEventTarget !== e.target) { - element = initialEventTarget; + // loop through keys one at a time and bind the appropriate callback + // function. for any key leading up to the final one it should + // increase the sequence. after the final, it should reset all sequences + // + // if an action is specified in the original bind call then that will + // be used throughout. otherwise we will pass the action that the + // next key in the sequence should match. + for (let i = 0; i < keys.length; ++i) { + const isFinal = i + 1 === keys.length; + const wrappedCallback = isFinal + ? _callbackAndReset + : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); + this._bindSingle(keys[i], wrappedCallback, action, combo, i); } } - // stop for input, select, textarea and button - const shouldStop = - element.tagName == "INPUT" || - element.tagName == "SELECT" || - element.tagName == "TEXTAREA" || - (element.tagName == "BUTTON" && combo.includes("tab")) || - (element.contentEditable && - (element.contentEditable == "true" || - element.contentEditable === "plaintext-only")); + /** + * resets all sequence counters except for the ones passed in + * + * @param {Object} doNotReset + * @returns void + */ + _resetSequences(doNotReset) { + doNotReset = doNotReset || {}; - return shouldStop; -}; + let activeSequences = false; -/** - * exposes _handleKey publicly so it can be overwritten by extensions - */ -Mousetrap.prototype.handleKey = function () { - var self = this; - return self._handleKey.apply(self, arguments); -}; + for (const key in this._sequenceLevels) { + if (doNotReset[key]) { + activeSequences = true; + continue; + } + this._sequenceLevels[key] = 0; + } -/** - * allow custom key mappings - */ -export function addKeycodes(object) { - for (var key in object) { - if (object.hasOwnProperty(key)) { - _MAP[key] = object[key]; + if (!activeSequences) { + this._nextExpectedAction = false; } } - _REVERSE_MAP = null; + + /** + * called to set a 1 second timeout on the specified sequence + * + * this is so after each key press in the sequence you have 1 second + * to press the next key before you have to start over + * + * @returns void + */ + _resetSequenceTimer() { + clearTimeout(this._resetTimer); + this._resetTimer = setTimeout(() => this._resetSequences(), 1000); + } + + /** + * finds all callbacks that match based on the key, modifiers, + * and action + * + * @param {string} character canonical lowercase key + * @param {Array} modifiers + * @param {Event|Object} e + * @param {string=} sequenceName - name of the sequence we are looking for + * @param {string=} combination + * @param {number=} level + * @returns {Array} + */ + _getMatches(character, modifiers, e, sequenceName, combination, level) { + const matches = []; + const action = e.type; + + // if there are no events related to this key + if (!this._callbacks[character]) { + return []; + } + + // if a modifier key is coming up on its own we should allow it + if (action == "keyup" && _isModifier(character)) { + modifiers = [character]; + } + + // loop through all callbacks for the key that was pressed + // and see if any of them match + for (let i = 0; i < this._callbacks[character].length; ++i) { + const callback = this._callbacks[character][i]; + + // if a sequence name is not specified, but this is a sequence at + // the wrong level then move onto the next match + if ( + !sequenceName && + callback.seq && + this._sequenceLevels[callback.seq] != callback.level + ) { + continue; + } + + // if the action we are looking for doesn't match the action we got + // then we should keep going + if (action != callback.action) { + continue; + } + + if (_modifiersMatch(modifiers, callback.modifiers)) { + // when you bind a combination or sequence a second time it + // should overwrite the first one. if a sequenceName or + // combination is specified in this call it does just that + // + // @todo make deleting its own method? + const deleteCombo = !sequenceName && callback.combo == combination; + const deleteSequence = + sequenceName && + callback.seq == sequenceName && + callback.level == level; + if (deleteCombo || deleteSequence) { + this._callbacks[character].splice(i, 1); + } + + matches.push(callback); + } + } + + return matches; + } + + /** + * actually calls the callback function + * + * if your callback function returns false this will use the jquery + * convention - prevent default and stop propogation on the event + * + * @param {Function} callback + * @param {Event} e + * @returns void + */ + _fireCallback(callback, e, combo, sequence) { + // if this event should not happen stop here + if (this.stopCallback(e, e.target || e.srcElement, combo, sequence)) { + return; + } + + if (callback(e, combo) === false) { + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * handles a key event + * + * @param {Event} e + * @returns void + */ + _handleKeyEvent(e) { + const character = this._characterFromEvent(e); + // no character found then stop + if (!character) { + return; + } + + if (e.type == "keyup" && this._ignoreNextKeyup === character) { + this._ignoreNextKeyup = false; + return; + } + + this.handleKey(character, _eventModifiers(e), e); + } } /** @@ -1095,6 +883,8 @@ export function addKeycodes(object) { * This method is needed to allow the global mousetrap functions to work * now that mousetrap is a constructor function. */ -var instance = new Mousetrap(globalDocument); +const instance = new Mousetrap(globalDocument); + +export { Mousetrap }; export default instance; diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 28ca48e04a..3eb605929d 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,10 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "scripts": { + "test": "node --test \"test/*.test.js\"" + }, + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/mousetrap/test/bind-dispatch.test.js b/frontend/packages/mousetrap/test/bind-dispatch.test.js new file mode 100644 index 0000000000..7512151126 --- /dev/null +++ b/frontend/packages/mousetrap/test/bind-dispatch.test.js @@ -0,0 +1,183 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent, makeSpiedEvent } from "./helpers.js"; + +describe("bind and dispatch", () => { + it("binds a single key", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("m", () => { + calls++; + }); + fire(target, makeKeyEvent({ key: "m", code: "KeyM" })); + assert.equal(calls, 1); + }); + + it("binds an array of combos to the same callback", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + const cb = () => { + calls++; + }; + mousetrap.bind(["del", "backspace"], cb); + fire(target, makeKeyEvent({ key: "Delete", code: "Delete" })); + fire(target, makeKeyEvent({ key: "Backspace", code: "Backspace" })); + assert.equal(calls, 2); + }); + + it("supports the plus forms", () => { + const { mousetrap, target } = freshInstance(); + let plus = 0; + let shiftPlus = 0; + mousetrap.bind("+", () => { + plus++; + }); + mousetrap.bind("shift++", () => { + shiftPlus++; + }); + fire(target, makeKeyEvent({ key: "+", code: "Equal" })); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + // "*" normalizes by glyph, so "shift++" (shift + "+" key) does not fire; + // this pins the layout-independent behavior for the "+" physical key. + assert.equal(plus, 1); + assert.equal(shiftPlus, 0); + }); + + it("resolves bind-string aliases to canonical tokens", () => { + const cases = [ + ["control+z", { key: "z", code: "KeyZ", ctrlKey: true }], + ["command+z", { key: "z", code: "KeyZ", metaKey: true }], + ["option+q", { key: "q", code: "KeyQ", altKey: true }], + ["mod+z", { key: "z", code: "KeyZ", ctrlKey: true }], + ["return", { key: "Enter", code: "Enter" }], + ["esc", { key: "Escape", code: "Escape" }], + ["plus", { key: "+", code: "Equal" }], + ]; + for (const [combo, eventOver] of cases) { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind(combo, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + assert.equal(calls, 1, combo); + } + }); + + it("defaults to keydown and honors explicit keyup", () => { + const { mousetrap, target } = freshInstance(); + let downs = 0; + let ups = 0; + mousetrap.bind("x", () => { + downs++; + }); + mousetrap.bind( + "y", + () => { + ups++; + }, + "keyup", + ); + fire(target, makeKeyEvent({ key: "x", code: "KeyX", type: "keyup" })); + fire(target, makeKeyEvent({ key: "y", code: "KeyY" })); + fire(target, makeKeyEvent({ key: "y", code: "KeyY", type: "keyup" })); + assert.equal(downs, 0); + assert.equal(ups, 1); + }); + + it("fires a bare modifier on keyup", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind( + "shift", + () => { + calls++; + }, + "keyup", + ); + fire(target, makeKeyEvent({ key: "Shift", code: "ShiftLeft", type: "keyup", shiftKey: true })); + assert.equal(calls, 1); + }); + + it("calls preventDefault and stopPropagation when the callback returns false", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.bind("ctrl+s", () => false); + const { event, calls } = makeSpiedEvent({ key: "s", code: "KeyS", ctrlKey: true }); + fire(target, event); + assert.equal(calls.preventDefault, 1); + assert.equal(calls.stopPropagation, 1); + }); + + it("reset() clears every binding", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("a", () => { + calls++; + }); + mousetrap.reset(); + fire(target, makeKeyEvent({ key: "a", code: "KeyA" })); + assert.equal(calls, 0); + }); + + it("overwrite replaces the previous callback for the combo", () => { + const { mousetrap, target } = freshInstance(); + const seen = []; + mousetrap.bind("ctrl+z", () => { + seen.push("old"); + }); + mousetrap.bind( + "ctrl+z", + () => { + seen.push("new"); + }, + undefined, + true, + ); + fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true })); + assert.deepEqual(seen, ["new"]); + }); + + it("unbind pins the current noop behavior (TODO: really remove)", () => { + // NOTE: unbind() binds an empty function on top instead of removing + // the original, so the original still fires. Pinned as-is; fixing it + // is a behavior change out of scope. + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("a", () => { + calls++; + }); + mousetrap.unbind("a"); + fire(target, makeKeyEvent({ key: "a", code: "KeyA" })); + assert.equal(calls, 1); + }); + + it("trigger dispatches a bind registered with an explicit action", () => { + const { mousetrap } = freshInstance(); + let calls = 0; + let seenCombo = null; + mousetrap.bind( + "ctrl+q", + (e, combo) => { + calls++; + seenCombo = combo; + }, + "keydown", + ); + mousetrap.trigger("ctrl+q", "keydown"); + assert.equal(calls, 1); + assert.equal(seenCombo, "ctrl+q"); + }); + + it("trigger documents the default-action quirk (TODO)", () => { + // NOTE: binds without an explicit action are stored under + // "combo:undefined", so trigger(combo, "keydown") misses them. + // Pinned as-is; fixing it is a behavior change out of scope. + const { mousetrap } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+q", () => { + calls++; + }); + mousetrap.trigger("ctrl+q", "keydown"); + assert.equal(calls, 0); + }); +}); diff --git a/frontend/packages/mousetrap/test/helpers.js b/frontend/packages/mousetrap/test/helpers.js new file mode 100644 index 0000000000..d6061984a1 --- /dev/null +++ b/frontend/packages/mousetrap/test/helpers.js @@ -0,0 +1,89 @@ +import { Mousetrap } from "../index.js"; + +/** + * Fake event target capturing listeners. The Mousetrap class only needs + * `addEventListener`, so tests run in plain Node without a DOM. + */ +export function makeTarget() { + const listeners = {}; + const target = { + listeners, + addEventListener(type, callback) { + listeners[type] = listeners[type] || []; + listeners[type].push(callback); + }, + }; + return target; +} + +/** + * Fake focused element for `stopCallback`. Covers every property the + * implementation reads: dataset, className, tagName, contentEditable. + */ +export function makeElement(over = {}) { + return { + dataset: {}, + className: "", + tagName: "DIV", + contentEditable: "false", + ...over, + }; +} + +/** + * Synthetic keyboard event with sane defaults. Override any field per + * case (`key`, `code`, `shiftKey`, ...). The `target` defaults to a plain + * DIV so `stopCallback` lets the event through. + */ +export function makeKeyEvent(over = {}) { + return { + type: "keydown", + key: "a", + code: "KeyA", + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + target: makeElement(), + ...over, + }; +} + +/** + * Dispatches a synthetic event through every listener registered for + * its type on a fake target. + */ +export function fire(target, event) { + for (const callback of target.listeners[event.type] || []) { + callback(event); + } +} + +/** + * Event spies recording `preventDefault`/`stopPropagation` calls for the + * `callback → false` convention. + */ +export function makeSpiedEvent(over = {}) { + const calls = { preventDefault: 0, stopPropagation: 0 }; + const event = makeKeyEvent({ + preventDefault() { + calls.preventDefault++; + }, + stopPropagation() { + calls.stopPropagation++; + }, + ...over, + }); + return { event, calls }; +} + +/** + * Fresh isolated instance plus its fake target. Timers used by sequences + * are real by default; tests needing determinism enable `mock.timers` + * themselves and restore it afterwards. + */ +export function freshInstance() { + const target = makeTarget(); + const mousetrap = new Mousetrap(target); + return { mousetrap, target }; +} diff --git a/frontend/packages/mousetrap/test/layout-map.test.js b/frontend/packages/mousetrap/test/layout-map.test.js new file mode 100644 index 0000000000..3cb34eb722 --- /dev/null +++ b/frontend/packages/mousetrap/test/layout-map.test.js @@ -0,0 +1,95 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function firedWithMap(map, bindStr, eventOver) { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(map); + let calls = 0; + mousetrap.bind(bindStr, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + return calls; +} + +const spanishMap = { + get(code) { + return code === "BracketRight" ? "+" : undefined; + }, +}; + +describe("layout map", () => { + it("resolves punctuation by position, so Shift+* fires a shift++ bind", () => { + const event = { key: "*", code: "BracketRight", shiftKey: true }; + assert.equal(firedWithMap(spanishMap, "shift++", event), 1); + assert.equal(firedWithMap(spanishMap, "shift+*", event), 0); + }); + + it("matches the unshifted primary on the same position", () => { + const event = { key: "+", code: "BracketRight" }; + assert.equal(firedWithMap(spanishMap, "+", event), 1); + }); + + it("falls back to the glyph when the map misses the code", () => { + const miss = { get: () => undefined }; + const event = { key: "*", code: "BracketRight", shiftKey: true }; + assert.equal(firedWithMap(miss, "shift+*", event), 1); + assert.equal(firedWithMap(miss, "shift++", event), 0); + }); + + it("falls back to the glyph when the map throws", () => { + const broken = { + get() { + throw new Error("no map"); + }, + }; + assert.equal( + firedWithMap(broken, "shift+*", { key: "*", code: "BracketRight", shiftKey: true }), + 1, + ); + }); + + it("setLayoutMap(null) restores the glyph fallback", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + mousetrap.setLayoutMap(null); + let calls = 0; + mousetrap.bind("shift+*", () => { + calls++; + }); + fire(target, makeKeyEvent({ key: "*", code: "BracketRight", shiftKey: true })); + assert.equal(calls, 1); + }); + + it("maps stay per instance", () => { + const a = freshInstance(); + const b = freshInstance(); + a.mousetrap.setLayoutMap(spanishMap); + let callsA = 0; + let callsB = 0; + a.mousetrap.bind("shift++", () => { + callsA++; + }); + b.mousetrap.bind("shift+*", () => { + callsB++; + }); + const event = { key: "*", code: "BracketRight", shiftKey: true }; + fire(a.target, makeKeyEvent(event)); + fire(b.target, makeKeyEvent(event)); + assert.equal(callsA, 1); + assert.equal(callsB, 1); + }); + + it("letters still resolve by glyph with a map set", () => { + const { mousetrap, target } = freshInstance(); + mousetrap.setLayoutMap(spanishMap); + let calls = 0; + mousetrap.bind("a", () => { + calls++; + }); + // AZERTY: the "A" label sits on the KeyQ position. + fire(target, makeKeyEvent({ key: "a", code: "KeyQ" })); + assert.equal(calls, 1); + }); +}); diff --git a/frontend/packages/mousetrap/test/modifiers.test.js b/frontend/packages/mousetrap/test/modifiers.test.js new file mode 100644 index 0000000000..748b71feeb --- /dev/null +++ b/frontend/packages/mousetrap/test/modifiers.test.js @@ -0,0 +1,63 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function fired(bindStr, eventOver) { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind(bindStr, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + return calls; +} + +describe("modifiers", () => { + it("matches each flag on its own", () => { + assert.equal(fired("shift+x", { key: "x", code: "KeyX", shiftKey: true }), 1); + assert.equal(fired("alt+x", { key: "x", code: "KeyX", altKey: true }), 1); + assert.equal(fired("ctrl+x", { key: "x", code: "KeyX", ctrlKey: true }), 1); + assert.equal(fired("meta+x", { key: "x", code: "KeyX", metaKey: true }), 1); + }); + + it("does not fire without the bound modifier", () => { + assert.equal(fired("shift+x", { key: "x", code: "KeyX" }), 0); + assert.equal(fired("ctrl+x", { key: "x", code: "KeyX", altKey: true }), 0); + }); + + it("combines modifiers regardless of bind order", () => { + const event = { key: "q", code: "KeyQ", ctrlKey: true, altKey: true, shiftKey: true }; + assert.equal(fired("ctrl+alt+shift+q", event), 1); + assert.equal(fired("shift+ctrl+alt+q", event), 1); + }); + + it("drops the phantom ctrl while AltGraph is active", () => { + const altgr = { + key: "]", + code: "BracketRight", + ctrlKey: true, + altKey: true, + getModifierState: (mod) => mod === "AltGraph", + }; + assert.equal(fired("ctrl+alt+]", altgr), 0); + assert.equal(fired("alt+]", altgr), 1); + }); + + it("keeps a real ctrl+alt without AltGraph", () => { + const event = { + key: "]", + code: "BracketRight", + ctrlKey: true, + altKey: true, + getModifierState: () => false, + }; + assert.equal(fired("ctrl+alt+]", event), 1); + }); + + it("works when the event has no getModifierState at all", () => { + assert.equal( + fired("ctrl+x", { key: "x", code: "KeyX", ctrlKey: true, getModifierState: undefined }), + 1, + ); + }); +}); diff --git a/frontend/packages/mousetrap/test/normalize.test.js b/frontend/packages/mousetrap/test/normalize.test.js new file mode 100644 index 0000000000..b91b5ff40d --- /dev/null +++ b/frontend/packages/mousetrap/test/normalize.test.js @@ -0,0 +1,109 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function fired(bindStr, eventOver) { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind(bindStr, () => { + calls++; + }); + fire(target, makeKeyEvent(eventOver)); + return calls; +} + +describe("canonical key normalization", () => { + // [native event.key, code, canonical bind] — every _KEY_NAMES entry. + const table = [ + [" ", "Space", "space"], + ["Spacebar", "Space", "space"], + ["ArrowUp", "ArrowUp", "up"], + ["Up", "ArrowUp", "up"], + ["ArrowDown", "ArrowDown", "down"], + ["Down", "ArrowDown", "down"], + ["ArrowLeft", "ArrowLeft", "left"], + ["Left", "ArrowLeft", "left"], + ["ArrowRight", "ArrowRight", "right"], + ["Right", "ArrowRight", "right"], + ["Escape", "Escape", "escape"], + ["Esc", "Escape", "escape"], + ["Enter", "Enter", "enter"], + ["Backspace", "Backspace", "backspace"], + ["Delete", "Delete", "del"], + ["Del", "Delete", "del"], + ["Insert", "Insert", "ins"], + ["Ins", "Insert", "ins"], + ["Tab", "Tab", "tab"], + ["Home", "Home", "home"], + ["End", "End", "end"], + ["PageUp", "PageUp", "pageup"], + ["PageDown", "PageDown", "pagedown"], + ["CapsLock", "CapsLock", "capslock"], + ]; + + for (const [nativeKey, code, canonical] of table) { + it(`maps ${nativeKey} to ${canonical}`, () => { + assert.equal(fired(canonical, { key: nativeKey, code }), 1); + }); + } + + it("lowercases single characters so Shift+Z matches shift+z", () => { + assert.equal(fired("shift+z", { key: "Z", code: "KeyZ", shiftKey: true }), 1); + assert.equal(fired("a", { key: "A", code: "KeyA" }), 1); + }); + + it("resolves a shifted digit row key to the digit itself", () => { + // US Shift+0 types ")", AZERTY Shift+0 types "0": shift+0 must mean + // the physical digit key on every layout (production keyCode behavior). + assert.equal(fired("shift+0", { key: ")", code: "Digit0", shiftKey: true }), 1); + assert.equal(fired("shift+1", { key: "!", code: "Digit1", shiftKey: true }), 1); + assert.equal(fired("shift+2", { key: "@", code: "Digit2", shiftKey: true }), 1); + assert.equal(fired("shift+8", { key: "*", code: "Digit8", shiftKey: true }), 1); + // ...so the shifted glyphs are not bindable tokens for digit codes. + assert.equal(fired("shift+)", { key: ")", code: "Digit0", shiftKey: true }), 0); + assert.equal(fired("shift+*", { key: "*", code: "Digit8", shiftKey: true }), 0); + // AZERTY shifted digits already type the digit: unchanged. + assert.equal(fired("shift+0", { key: "0", code: "Digit0", shiftKey: true }), 1); + // Unshifted digits are untouched. + assert.equal(fired("0", { key: "0", code: "Digit0" }), 1); + assert.equal(fired("&", { key: "&", code: "Digit1" }), 1); + }); + + it("without a layout map, punctuation resolves by glyph", () => { + // Spanish layout: Shift + the physical "+" key (BracketRight) types "*". + assert.equal(fired("shift+*", { key: "*", code: "BracketRight", shiftKey: true }), 1); + assert.equal(fired("shift+]", { key: "*", code: "BracketRight", shiftKey: true }), 0); + assert.equal(fired("+", { key: "+", code: "BracketRight" }), 1); + // US layout: Shift + BracketRight types "}". + assert.equal(fired("shift+}", { key: "}", code: "BracketRight", shiftKey: true }), 1); + assert.equal(fired("shift+]", { key: "}", code: "BracketRight", shiftKey: true }), 0); + assert.equal(fired("?", { key: "?", code: "Slash" }), 1); + }); + + it("lowercases function keys", () => { + assert.equal(fired("f1", { key: "F1", code: "F1" }), 1); + assert.equal(fired("f13", { key: "F13", code: "F13" }), 1); + }); + + it("ignores Dead keys and missing/empty keys without throwing", () => { + assert.equal(fired("dead", { key: "Dead", code: "Quote" }), 0); + assert.equal(fired("a", { key: undefined, code: "KeyA" }), 0); + assert.equal(fired("a", { key: "", code: "KeyA" }), 0); + }); + + it("keeps numpad digits positional as num0..num9", () => { + assert.equal(fired("num0", { key: "0", code: "Numpad0" }), 1); + assert.equal(fired("num9", { key: "9", code: "Numpad9" }), 1); + // NumLock off: the key says Insert but the position wins. + assert.equal(fired("num0", { key: "Insert", code: "Numpad0" }), 1); + }); + + it("falls back to the glyph for non-digit numpad codes", () => { + assert.equal(fired("+", { key: "+", code: "NumpadAdd" }), 1); + }); + + it("lowercases unknown long keys", () => { + assert.equal(fired("foo", { key: "Foo", code: "Foo" }), 1); + assert.equal(fired("ñ", { key: "Ñ", code: "Semicolon" }), 1); + }); +}); diff --git a/frontend/packages/mousetrap/test/sequences.test.js b/frontend/packages/mousetrap/test/sequences.test.js new file mode 100644 index 0000000000..69098258d9 --- /dev/null +++ b/frontend/packages/mousetrap/test/sequences.test.js @@ -0,0 +1,108 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +function down(over) { + return makeKeyEvent({ type: "keydown", ...over }); +} + +function up(over) { + return makeKeyEvent({ type: "keyup", ...over }); +} + +describe("sequences", () => { + beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + }); + + afterEach(() => { + mock.timers.reset(); + }); + + it("fires after the full sequence, not on the prefix alone", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("g v", () => { + calls++; + }); + fire(target, down({ key: "g", code: "KeyG" })); + assert.equal(calls, 0); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 1); + }); + + it("advances every sequence sharing a prefix", () => { + const { mousetrap, target } = freshInstance(); + const seen = []; + mousetrap.bind("g i", () => { + seen.push("i"); + }); + mousetrap.bind("g t", () => { + seen.push("t"); + }); + fire(target, down({ key: "g", code: "KeyG" })); + fire(target, down({ key: "t", code: "KeyT" })); + assert.deepEqual(seen, ["t"]); + }); + + it("resets on a wrong key mid-sequence", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("g v", () => { + calls++; + }); + fire(target, down({ key: "g", code: "KeyG" })); + fire(target, down({ key: "e", code: "KeyE" })); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 0); + // and the sequence works again afterwards + fire(target, down({ key: "g", code: "KeyG" })); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 1); + }); + + it("expires after one second without the next key", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("g v", () => { + calls++; + }); + fire(target, down({ key: "g", code: "KeyG" })); + mock.timers.tick(1001); + fire(target, down({ key: "v", code: "KeyV" })); + assert.equal(calls, 0); + }); + + it("supports modifiers inside sequences", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+k u", () => { + calls++; + }); + fire(target, down({ key: "k", code: "KeyK", ctrlKey: true })); + fire(target, down({ key: "u", code: "KeyU" })); + assert.equal(calls, 1); + }); + + it("supports keyup sequences and ignores the final keyup", () => { + const { mousetrap, target } = freshInstance(); + const seen = []; + mousetrap.bind( + "g v", + () => { + seen.push("seq"); + }, + "keyup", + ); + mousetrap.bind( + "v", + () => { + seen.push("keyup-v"); + }, + "keyup", + ); + fire(target, up({ key: "g", code: "KeyG" })); + fire(target, up({ key: "v", code: "KeyV" })); + assert.deepEqual(seen, ["seq"]); + }); +}); diff --git a/frontend/packages/mousetrap/test/smoke.test.js b/frontend/packages/mousetrap/test/smoke.test.js new file mode 100644 index 0000000000..ae3498f046 --- /dev/null +++ b/frontend/packages/mousetrap/test/smoke.test.js @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { freshInstance, fire, makeKeyEvent } from "./helpers.js"; + +describe("smoke", () => { + it("fires a bound combo and ignores it without the modifier", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+z", () => { + calls++; + }); + + fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true })); + fire(target, makeKeyEvent({ key: "z", code: "KeyZ" })); + + assert.equal(calls, 1); + }); + + it("reset() leaves the instance deaf", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+z", () => { + calls++; + }); + mousetrap.reset(); + + fire(target, makeKeyEvent({ key: "z", code: "KeyZ", ctrlKey: true })); + + assert.equal(calls, 0); + }); +}); diff --git a/frontend/packages/mousetrap/test/stop-callback.test.js b/frontend/packages/mousetrap/test/stop-callback.test.js new file mode 100644 index 0000000000..b02303dcec --- /dev/null +++ b/frontend/packages/mousetrap/test/stop-callback.test.js @@ -0,0 +1,102 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Mousetrap } from "../index.js"; +import { + freshInstance, + fire, + makeKeyEvent, + makeElement, +} from "./helpers.js"; + +describe("stopCallback", () => { + function stopped(elementOver, eventOver = {}, combo = "ctrl+z") { + const { mousetrap } = freshInstance(); + const event = makeKeyEvent({ target: makeElement(elementOver), ...eventOver }); + return mousetrap.stopCallback(event, event.target, combo); + } + + it("stops inside form fields", () => { + for (const tagName of ["INPUT", "SELECT", "TEXTAREA"]) { + assert.equal(stopped({ tagName }), true, tagName); + } + }); + + it("stops buttons only for tab combos, tolerating a missing combo", () => { + assert.equal(stopped({ tagName: "BUTTON" }, {}, "shift+tab"), true); + assert.equal(stopped({ tagName: "BUTTON" }, {}, "ctrl+z"), false); + assert.equal( + stopped({ tagName: "BUTTON" }, {}, undefined), + false, + ); + }); + + it("stops editable content", () => { + assert.equal(stopped({ contentEditable: "true" }), true); + assert.equal(stopped({ contentEditable: "plaintext-only" }), true); + assert.equal(stopped({ contentEditable: "false" }), false); + assert.equal(stopped({ contentEditable: "inherit" }), false); + }); + + it("lets events through on mousetrap-marked elements", () => { + assert.equal(stopped({ className: "mousetrap" }), false); + assert.equal(stopped({ className: "foo mousetrap bar" }), false); + assert.equal(stopped({ dataset: { mousetrapDontStop: "" } }), false); + }); + + it("always lets keyup through", () => { + assert.equal(stopped({ tagName: "INPUT" }, { type: "keyup" }), false); + }); + + it("retargets through composedPath in open shadow trees", () => { + const inner = makeElement({ tagName: "DIV" }); + const event = makeKeyEvent({ + target: makeElement({ tagName: "INPUT" }), + composedPath: () => [inner], + }); + const { mousetrap } = freshInstance(); + assert.equal(mousetrap.stopCallback(event, event.target, "ctrl+z"), false); + }); + + it("gates dispatch end to end: typing in an input fires nothing", () => { + const { mousetrap, target } = freshInstance(); + let calls = 0; + mousetrap.bind("ctrl+z", () => { + calls++; + }); + fire( + target, + makeKeyEvent({ + key: "z", + code: "KeyZ", + ctrlKey: true, + target: makeElement({ tagName: "INPUT" }), + }), + ); + assert.equal(calls, 0); + }); +}); + +describe("exports", () => { + it("exposes the shared default instance", async () => { + const mod = await import("../index.js"); + assert.equal(typeof mod.default.bind, "function"); + assert.equal(typeof mod.default.reset, "function"); + }); + + it("constructs without a target in Node: no listeners, no throw", () => { + const mousetrap = new Mousetrap(); + assert.deepEqual(mousetrap._callbacks, {}); + }); + + it("wires keydown and keyup on the given target", async () => { + const { Mousetrap: Klass } = await import("../index.js"); + const seen = []; + const target = { + addEventListener(type) { + seen.push(type); + }, + }; + new Klass(target); + assert.deepEqual(seen, ["keydown", "keyup"]); + }); +}); 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 8cead2bb50..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", - "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/packages/ui/package.json b/frontend/packages/ui/package.json index 0462fb2dde..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "exports": { ".": { "import": "./dist/index.js" 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/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/text-editor/get-file-12325.json b/frontend/playwright/data/text-editor/get-file-12325.json new file mode 100644 index 0000000000..2c039f5bb8 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-12325.json @@ -0,0 +1,727 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~: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": "Bug 12325", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~: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 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 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, + "~:r4": 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": [ + "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2" + ] + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~:name": "Text board", + "~:type": "~:frame", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:shapes": ["~uc0a701d6-7df9-80ac-8008-c9e6c47fbce4"], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:strokes": [], + "~:hide-fill-on-export": false, + "~:show-content": true, + "~:proportion-lock": false, + "~:proportion": 1, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:layout": "~:flex", + "~:layout-flex-dir": "~:row", + "~:layout-wrap-type": "~:nowrap", + "~:layout-gap-type": "~:multiple", + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:layout-padding-type": "~:simple", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:layout-align-items": "~:start", + "~:layout-justify-content": "~:start", + "~:layout-align-content": "~:stretch", + "~:layout-item-h-sizing": "~:auto", + "~:layout-item-v-sizing": "~:auto", + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 17 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 17 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:x1": 0, + "~:y1": 0, + "~:x2": 99.99999904632568, + "~:y2": 17 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c47fbce4": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c47fbce4", + "~:name": "Hello wide world", + "~:type": "~:text", + "~:grow-type": "~:auto-width", + "~:parent-id": "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~:frame-id": "~uc0a701d6-7df9-80ac-8008-c9e6c5b7f7f4", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:type": "paragraph", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Hello wide world" + } + ] + } + ] + } + ] + }, + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 99.99999904632568, + "~:y": 17 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 17 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 99.99999904632568, + "~:height": 17, + "~:x1": 0, + "~:y1": 0, + "~:x2": 99.99999904632568, + "~:y2": 17 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:name": "Group board", + "~:type": "~:frame", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:shapes": ["~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a"], + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:strokes": [], + "~:hide-fill-on-export": false, + "~:show-content": true, + "~:proportion-lock": false, + "~:proportion": 1, + "~:r1": 0, + "~:r2": 0, + "~:r3": 0, + "~:r4": 0, + "~:layout": "~:flex", + "~:layout-flex-dir": "~:row", + "~:layout-wrap-type": "~:nowrap", + "~:layout-gap-type": "~:multiple", + "~:layout-gap": { + "~:row-gap": 0, + "~:column-gap": 0 + }, + "~:layout-padding-type": "~:simple", + "~:layout-padding": { + "~:p1": 0, + "~:p2": 0, + "~:p3": 0, + "~:p4": 0 + }, + "~:layout-align-items": "~:start", + "~:layout-justify-content": "~:start", + "~:layout-align-content": "~:stretch", + "~:layout-item-h-sizing": "~:auto", + "~:layout-item-v-sizing": "~:auto", + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 117 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 117 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:x1": 0, + "~:y1": 100, + "~:x2": 120.99999821186066, + "~:y2": 117 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a", + "~:name": "Group", + "~:type": "~:group", + "~:parent-id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:frame-id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:shapes": ["~uc0a701d6-7df9-80ac-8008-c9e6c781a50f"], + "~:fills": [], + "~:strokes": [], + "~:proportion-lock": false, + "~:proportion": 1, + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 117 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 117 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:x1": 0, + "~:y1": 100, + "~:x2": 120.99999821186066, + "~:y2": 117 + } + } + } + }, + "~uc0a701d6-7df9-80ac-8008-c9e6c781a50f": { + "~#shape": { + "~:id": "~uc0a701d6-7df9-80ac-8008-c9e6c781a50f", + "~:name": "Hello grouped world", + "~:type": "~:text", + "~:grow-type": "~:auto-width", + "~:parent-id": "~uc0a701d6-7df9-80ac-8008-c9e6ca7a304a", + "~:frame-id": "~uc0a701d6-7df9-80ac-8008-c9e6c8b2a9e2", + "~:content": { + "~:type": "root", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:type": "paragraph", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Hello grouped world" + } + ] + } + ] + } + ] + }, + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 100 + } + }, + { + "~#point": { + "~:x": 120.99999821186066, + "~:y": 117 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 117 + } + } + ], + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 100, + "~:width": 120.99999821186066, + "~:height": 17, + "~:x1": 0, + "~:y1": 100, + "~:x2": 120.99999821186066, + "~:y2": 117 + } + } + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} 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/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/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/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/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/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/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/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/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/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 04efe9f73c..894640a028 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -23,7 +23,6 @@ async function openEditorAndSelectAll(workspace) { await workspace.page.keyboard.press("ControlOrMeta+a"); } - test("Typography at a collapsed caret only styles newly typed text", async ({ page, }) => { @@ -218,8 +217,6 @@ test.describe("BUG 11083 - Changing typography must not quit the editor", () => }); }); - - test("BUG 10467 - Auto-width text captures every typed character", async ({ page, }) => { @@ -408,10 +405,13 @@ test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", ( await workspace.goToWorkspace(); await workspace.waitForFirstRender(); - // Select the text and zoom to fit, so it is fully visible in the viewport + // Select the text and zoom to fit, so it is fully visible in the viewport. + // Wait for the zoom label to change so the handles are at their final spot. await workspace.clickLeafLayer("Fixed text"); + const zoomLabel = page.getByTitle("Zoom", { exact: true }); + const initialZoom = await zoomLabel.innerText(); await page.keyboard.press("Shift+1"); - await workspace.waitForIdle(); + await expect(zoomLabel).not.toHaveText(initialZoom); return workspace; } @@ -678,3 +678,74 @@ test.describe("Text Editor context menu", () => { await expect(menuItem(page, "Select all")).not.toHaveAttribute("disabled"); }); }); + +test.describe("BUG 12325 - Fit-content flex board follows an auto-width text while typing", () => { + // Two grey fit-content flex boards, one per test. The content + // (0, 0)-(121, 117) loads centred in the viewport at zoom 1. + const CONTENT_CENTER = { x: 60.5, y: 58.5 }; + + // Screenshot of a small canvas patch centred on (x, y), in canvas + // coordinates. + async function patchAt(workspace, x, y) { + const viewport = await workspace.viewport.boundingBox(); + const px = viewport.x + viewport.width / 2 + x - CONTENT_CENTER.x; + const py = viewport.y + viewport.height / 2 + y - CONTENT_CENTER.y; + return workspace.page.screenshot({ + clip: { x: px - 2, y: py - 2, width: 4, height: 4 }, + }); + } + + async function openFile(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-12325.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + return workspace; + } + + // Shrinks `text` to its first character while editing and checks that a spot + // in the right part of its board, at `boardY`, shows the empty canvas. + async function expectBoardShrinksWhileTyping(workspace, text, boardY) { + const x = 80; + const inside = boardY + 8; + const outside = boardY + 50; + expect(await patchAt(workspace, x, inside)).not.toEqual( + await patchAt(workspace, x, outside), + ); + + await workspace.textEditor.startEditing(); + await workspace.page.keyboard.press("End"); + for (let i = 0; i < text.length - 1; i++) { + await workspace.page.keyboard.press("Backspace"); + } + + // Without leaving the editor, the board already shrank around the text, so + // the spot shows the empty canvas. + await expect + .poll(async () => + (await patchAt(workspace, x, inside)).equals( + await patchAt(workspace, x, outside), + ), + ) + .toBe(true); + } + + test("The board shrinks with the text", async ({ page }) => { + const workspace = await openFile(page); + await workspace.clickToggableLayer("Text board"); + await workspace.clickLeafLayer("Hello wide world"); + + await expectBoardShrinksWhileTyping(workspace, "Hello wide world", 0); + }); + + test("The board shrinks with a text inside a group", async ({ page }) => { + const workspace = await openFile(page); + await workspace.clickToggableLayer("Group board"); + // The first "Group" match is the board row. + await workspace.clickToggableLayer("Group", {}, 1); + await workspace.clickLeafLayer("Hello grouped world"); + + await expectBoardShrinksWhileTyping(workspace, "Hello grouped world", 100); + }); +}); diff --git a/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js b/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js index 3e99476fe5..b1da71a01f 100644 --- a/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js +++ b/frontend/playwright/ui/specs/viewer-inspect-exports.spec.js @@ -23,11 +23,8 @@ test("[View mode] Export presets are preserved when navigating between boards in // Enter inspect (code) mode await viewer.showCode(); - // Wait for the inspect panel to load - await page.waitForSelector(".main_ui_inspect_exports__add-export"); - // Add an export preset via the "+" button in the Export section - const addExportButton = page.locator(".main_ui_inspect_exports__add-export"); + const addExportButton = page.getByRole("button", { name: "Add export" }); await addExportButton.click(); // Verify the "Export 1 element" button appears, confirming the preset was added diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 3b783793f6..06e3cd2639 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -317,3 +317,315 @@ 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); + }); +}); + +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); + }); +}); + +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); + }); +}); + +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); + }); +}); + +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); + }); +}); + +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/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 8361c263fb..5dac8b7377 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -151,3 +151,294 @@ 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, + }); + }); +}); + +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, + }); + }); +}); + +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, + }); + }); +}); + +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, + }); + }); +}); + +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, + }); + }); +}); + +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, + }); + }); +}); + +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/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index df766736a0..5893ae0d80 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); @@ -53,7 +97,128 @@ 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("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, + }) => { + 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(); @@ -84,6 +249,224 @@ 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, + }) => { + 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("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("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 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(); + 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 }, + ); + + // 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 }, + ); + }); }); test.describe("Assets tab", () => { @@ -109,10 +492,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(); }); @@ -137,6 +525,297 @@ 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("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("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 + // 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("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); + 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("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("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); @@ -163,4 +842,384 @@ 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", () => { + 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); + 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("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); + 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, + }); + }); +}); + +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, + }); + }); +}); + +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, + }); + }); +}); + +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, + }); + }); +}); + +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); + }); +}); + +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/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3307055883..6a0901cc8c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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' @@ -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/resources/images/icons/hourglass.svg b/frontend/resources/images/icons/hourglass.svg new file mode 100644 index 0000000000..f20d4e15f1 --- /dev/null +++ b/frontend/resources/images/icons/hourglass.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index 009f4c1c4e..6b9622c5bc 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 @@ -280,6 +281,7 @@ lectores letras manifiesto menos +navegadores nosotros otras pero diff --git a/frontend/scripts/test b/frontend/scripts/test index 6d822b9542..c61c28b543 100755 --- a/frontend/scripts/test +++ b/frontend/scripts/test @@ -10,3 +10,8 @@ popd pnpm run lint:scss; pnpm run test; + +# Mousetrap JS unit tests (node:test, zero deps) +pushd packages/mousetrap; +pnpm test; +popd diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 06ffc849da..635572996f 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -17,7 +17,7 @@ [app.main.data.profile :as dp] [app.main.data.websocket :as ws] [app.main.data.workspace.mcp :as mcp] - [app.main.errors] + [app.main.errors :as errors] [app.main.features :as feat] [app.main.rasterizer :as thr] [app.main.store :as st] @@ -136,6 +136,8 @@ (i18n/init) (cur/init-styles) + (mf/set-update-scheduler {:on-trip errors/on-update-loop}) + (init-ui) (st/emit! (plugins/initialize) (initialize))))) diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index 6c948eef09..b04228694c 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -257,6 +257,8 @@ (watch [_ state _] (let [current-team-id (:current-team-id state) organization (:organization team) + team-name (or (:name team) + (dm/get-in state [:teams (:id team) :name])) current-team? (= (:id team) current-team-id)] (when (and (contains? cf/flags :admin-console) current-team?) @@ -266,14 +268,14 @@ ;; (tr "dashboard.team-belong-organization") ;; (tr "dashboard.team-no-longer-belong-organization") ;; notification is a server-provided key, dynamic by design + ;; args: team name, organization name #_{:clj-kondo/ignore [:penpot/tr-dynamic]} - (rx/of (ntf/show {:content (tr notification (:name organization)) + (rx/of (ntf/show {:content (tr notification team-name (:name organization)) :type :toast :level :info :timeout nil}))) (when (:id organization) - (check-team-sso current-team-id)))))) - ptk/UpdateEvent + (check-team-sso current-team-id)))))) ptk/UpdateEvent (update [_ state] (if (contains? cf/flags :admin-console) (let [team-id (:id team) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index f1eeba1a5a..a6c3a3db77 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -325,6 +325,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 @@ -355,6 +362,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/dashboard/shortcuts.cljs b/frontend/src/app/main/data/dashboard/shortcuts.cljs index b225a50060..f406151f9b 100644 --- a/frontend/src/app/main/data/dashboard/shortcuts.cljs +++ b/frontend/src/app/main/data/dashboard/shortcuts.cljs @@ -50,7 +50,8 @@ (def shortcut-create-new-project {:create-new-project {:tooltip "+" :label (fn [] (tr "shortcuts.create-new-project")) - :command "+" + :command ["+" "="] + :show-command "+" :section [:dashboard] :subsections [:generic] :fn #(st/emit! (dd/create-element))}}) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 27c0133cc4..e85c1c3cd3 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -6,17 +6,23 @@ (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] [app.main.data.persistence :as dwp] + [app.main.errors :as errors] [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] @@ -24,6 +30,68 @@ (def default-timeout 5000) +;; Longest wait for the pending changes of the exported files to be saved. +(def save-wait-timeout-ms 10000) + +(defn- export-file-ids + [exports] + (into #{} (keep :file-id) exports)) + +(defn- warn-unsaved-changes + "Warns that the export may miss the changes of `file-ids` not saved yet." + [file-ids] + (ptk/reify ::warn-unsaved-changes + ptk/WatchEvent + (watch [_ state _] + (when (dwp/unsaved-changes? state file-ids) + (rx/of (ntf/show {:content (tr "workspace.export.unsaved-changes") + :type :toast + :level :warning + :timeout 8000})))))) + +(defn- export-failed + "Shows an export failure as a toast, keeping the workspace open: the + failure is never raised to the global error handler, which would + replace the workspace with an error page." + [cause] + (ptk/reify ::export-failed + ptk/EffectEvent + (effect [_ _ _] + (errors/flash :cause cause + :type (if (#{:server-error :internal} (:type (ex-data cause))) + :unhandled + :handled) + :hint (tr "errors.export-failed"))))) + +(defn- export-widget-failed + "Reports a failure that happened before the export ever started: the + widget settles instead of waiting for progress that will never arrive, + and the toast says what happened. Nothing is raised to the global + error handler, which would replace the workspace with an error page." + [exports cmd cause] + (ptk/reify ::export-widget-failed + ptk/UpdateEvent + (update [_ state] + (assoc state :export {:in-progress false + :widget-visible true + :detail-visible true + :healthy? true + :progress 0 + :total (count exports) + :exports exports + :cmd cmd + :error (or (ex-message cause) true) + :error-code (:code (ex-data cause)) + :last-update (ct/now)})) + + ptk/EffectEvent + (effect [_ _ _] + (errors/flash :cause cause + :type (if (#{:server-error :internal} (:type (ex-data cause))) + :unhandled + :handled) + :hint (tr "errors.export-failed"))))) + (defn normalize-export [{:keys [object-id name] :as export}] (assoc export :name (if (str/blank? name) @@ -170,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") @@ -200,47 +273,37 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) -;; The exporter is at capacity. Not a crash: the widget says so and the user -;; retries, instead of the generic error dialog. -(def ^:private saturation-codes #{:queue-full}) - -(defn- export-failed - "Reports a failure that happened before the export ever started, so the widget - settles instead of waiting for progress that will never arrive." - [exports cmd cause] - (ptk/reify ::export-failed - ptk/UpdateEvent - (update [_ state] - (assoc state :export {:in-progress false - :widget-visible true - :detail-visible true - :healthy? true - :progress 0 - :total (count exports) - :exports exports - :cmd cmd - :error (or (ex-message cause) true) - :error-code (:code (ex-data cause)) - :last-update (ct/now)})))) +(def ^:private local-export-job-id* + "Job id of the in-flight worker multi-export, if any." + (atom nil)) (defn cancel-export - "Stops the running export. Only reachable on the job API path, where the - exporter can actually abort the work. + "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. - 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." + 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. @@ -258,36 +321,56 @@ [state] (features/active-feature? state "render-wasm/v1")) -(defn- use-wasm-export? - "Whether to take the client-side WASM export path for `export`." - [state export] - (and (wasm-export-enabled? state) - (contains? wasm-export-types (:type export)))) +(defn- export-target + "Where a single-shape export runs. -(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))))) + 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) + (contains? wasm-export-types type))) + :exporter + + (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 + (if (= :too-large (wasm.api/export-target object-id (or scale 1) type)) + :too-large + :client))) + +(declare request-local-export-wasm) (defn request-simple-export [{:keys [export]}] - (let [export (normalize-export export)] + (let [export (normalize-export export) + target (volatile! nil)] (ptk/reify ::request-simple-export ptk/UpdateEvent (update [_ state] + (vreset! target (export-target state export)) (cond-> state - (not (use-wasm-export? state export)) + (= :exporter @target) (update :export assoc :in-progress true :id uuid/zero))) ptk/WatchEvent (watch [_ state _] - (if (use-wasm-export? state export) - (rx/of (request-simple-export-wasm export)) + (case @target + :client + (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"))) + (let [profile-id (:profile-id state) params (normalize-export-shapes-params {:exports [export] :profile-id profile-id @@ -295,104 +378,229 @@ :wait true :is-wasm (wasm-export-enabled? state)})] (rx/concat - (dwp/force-persist-and-wait 400) + (dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms) + (rx/of (warn-unsaved-changes (export-file-ids [export]))) (->> (rp/cmd! :export params) (rx/map (fn [{:keys [filename mtype uri]}] (dom/trigger-download-uri filename mtype uri) (clear-export-state uuid/zero))) (rx/catch (fn [cause] - (rx/concat - (rx/of (clear-export-state uuid/zero)) - (rx/throw cause)))))))))))) + (rx/of (clear-export-state uuid/zero) + (export-failed cause)))))))))))) + +(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 name] + [{: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) - 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/share)) - - stopper - (rx/filter #(or (= "ended" (:status %)) - (= "error" (:status %)) - (= "cancelled" (:status %))) - progress-stream)] - - (swap! st/ongoing-tasks conj :export) - - (rx/merge - ;; Force that all data is persisted; best effort. - (rx/of ::dwp/force-persist) - - ;; Launch 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. - (->> (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] - ;; Saturation is an answer, not a fault. - (if (contains? saturation-codes (:code (ex-data cause))) - (rx/of (export-failed exports cmd cause)) - (rx/concat - (rx/of (export-failed exports cmd cause)) - (rx/throw cause)))))) - - ;; We proceed to update the export state with incoming - ;; progress updates. We delay the stopper for give some time - ;; to update the status with ended or errored status before - ;; close the stream. - (->> progress-stream - (rx/map update-export-status) - (rx/take-until (rx/delay 500 stopper)) - (rx/finalize (fn [] - (swap! st/ongoing-tasks disj :export)))) - - ;; We hide need to hide the ui elements of the export after - ;; some interval. We also delay a little bit more the stopper - ;; for ensure that after some security time, the stream is - ;; completely closed. - (->> 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))))))))) + (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/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 5758f38ee4..87d64649d4 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -12,6 +12,7 @@ [app.main.data.event :as ev] [app.main.data.jobs :as dj] [app.main.data.modal :as modal] + [app.main.data.persistence :as dps] [app.main.repo :as rp] [app.main.store :as st] [beicon.v2.core :as rx] @@ -37,9 +38,25 @@ (def check-export-files (sm/check-fn schema:export-files)) +;; Longest wait for the pending changes of the downloaded files to be saved. +(def save-wait-timeout-ms 10000) + +(defn- show-export-dialog + "Opens the download dialog, flagging whether the files have changes not + saved yet." + [team-id files] + (ptk/reify ::show-export-dialog + ptk/WatchEvent + (watch [_ state _] + (rx/of (modal/show {:type ::export-files + :team-id team-id + :files files + :unsaved (dps/unsaved-changes? state (into #{} (map :id) files))}))))) + (defn open-export-dialog [files] - (let [files (check-export-files files)] + (let [files (check-export-files files) + file-ids (into #{} (map :id) files)] (ptk/reify ::export-files ptk/WatchEvent (watch [_ state _] @@ -49,16 +66,17 @@ ::ev/origin "dashboard" :format "binfile-v3" :num-files (count files)})) - (->> (rx/from files) - (rx/mapcat - (fn [file] - (->> (rp/cmd! :has-file-libraries {:file-id (:id file)}) - (rx/map #(assoc file :has-libraries %))))) - (rx/reduce conj []) - (rx/map (fn [files] - (modal/show {:type ::export-files - :team-id team-id - :files files})))))))))) + ;; The download is built from the server copy: send the pending + ;; changes first. + (rx/concat + (dps/force-persist-files-and-wait file-ids save-wait-timeout-ms) + (->> (rx/from files) + (rx/mapcat + (fn [file] + (->> (rp/cmd! :has-file-libraries {:file-id (:id file)}) + (rx/map #(assoc file :has-libraries %))))) + (rx/reduce conj []) + (rx/map (partial show-export-dialog team-id)))))))))) (defn- export-file "One file, one job: the file is exported on its own, so a failure does diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index ff56fd5796..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 [scale object-id]}] - (let [bytes (wasm.api/render-shape-pdf object-id (or scale 1)) - 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/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 2ec056487d..dca3c54263 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -115,6 +115,128 @@ :font-style font-style :uploads uploads}))))) +(defn- parse-font-mtype + "Detects a font mime-type from the first four bytes of an ArrayBuffer. + + Returns the `font/*` mime-type string, or nil when the buffer is + missing, too short, or carries an unknown signature. Unknown + signatures are treated as errors by `read-blob`, never silently + dropped." + [ba] + (when (and (some? ba) + (>= (.-byteLength ^js ba) 4)) + (let [u8 (js/Uint8Array. ba 0 4) + sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))] + (case sg + "117 124 124 117" "font/otf" + "0 1 0 0" "font/ttf" + "167 117 106 106" "font/woff" + "167 117 106 62" "font/woff2" + nil)))) + +(defn- parse-font + "Associates the opentype.js font object for TTF/OTF/WOFF blobs. + + Takes a `read-blob` result map. Passes `{:error ...}` maps through + untouched. WOFF2 blobs cannot be parsed by opentype.js, so they keep + `:font nil` and metadata is derived from the filename later. Any other + blob is parsed with opentype.js; when parsing fails an + `{:error filename}` map is returned so the caller can notify the user + instead of silently dropping the file." + [{:keys [error data type name] :as params}] + (cond + (some? error) + params + + (= type "font/woff2") + ;; woff2 cannot be parsed by opentype.js, extract metadata from filename + (assoc params :font nil) + + :else + (try + (assoc params :font (ot/parse data)) + (catch :default _e + (log/warn :msg (str/fmt "skipping file %s, unsupported format" name)) + {:error name})))) + +(defn- prepare-font + "Builds a ready-to-upload font entry from a parsed blob. + + Never throws for missing font metadata: a nil subfamily (opentype.js + returns nil when the font carries no English preferred/subfamily + names, as seen with some Fontshare families) defaults to regular + weight/style, and missing vertical-metric tables default to no + height warning." + [{:keys [font type name data] :as _params}] + (let [name (or name "")] + (if (some? font) + ;; Font was parsed with opentype.js (ttf, otf, woff) + (let [family (or (.getEnglishName ^js font "preferredFamily") + (.getEnglishName ^js font "fontFamily") + "") + variant (or (.getEnglishName ^js font "preferredSubfamily") + (.getEnglishName ^js font "fontSubfamily") + "") + + ;; Vertical metrics determine the baseline in a text and the space between lines of + ;; text. For historical reasons, there are three pairs of ascender/descender + ;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating + ;; system and application a different set will be used to render text on the + ;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox + ;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If + ;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea + ;; table. On Windows, all browsers use the usWin metrics, but respect the + ;; useTypoMetrics setting and if set will use the OS/2 values. + + hhea (some-> ^js font .-tables .-hhea) + os2 (some-> ^js font .-tables .-os2) + + hhea-ascender (some-> ^js hhea .-ascender abs) + hhea-descender (some-> ^js hhea .-descender abs) + + win-ascent (some-> ^js os2 .-usWinAscent abs) + win-descent (some-> ^js os2 .-usWinDescent abs) + + os2-ascent (some-> ^js os2 .-sTypoAscender abs) + os2-descent (some-> ^js os2 .-sTypoDescender abs) + + ;; useTypoMetrics can be read from the 7th bit + f-selection (some-> ^js os2 .-fsSelection (bit-test 7)) + + height-warning? (boolean + (and hhea-ascender hhea-descender win-ascent win-descent + (or (not= hhea-ascender win-ascent) + (not= hhea-descender win-descent) + (and f-selection os2-ascent os2-descent + (or (not= hhea-ascender os2-ascent) + (not= hhea-descender os2-descent)))))) + data (js/Uint8Array. data)] + {:content {:data data + :name name + :type type} + :font-family (or family "") + :font-weight (cm/parse-font-weight variant) + :font-style (cm/parse-font-style variant) + :variant-name variant + :height-warning? height-warning?}) + ;; Font could not be parsed (woff2), extract metadata from filename + (let [base-name (str/replace name #"\.[^.]+$" "") + ;; Strip known weight/style tokens and separators to derive family name + ;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold") + raw-family-name (-> base-name + (str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3") + (str/replace #"[-_\s]+" " ") + (str/trim)) + family-name (if (str/blank? raw-family-name) base-name raw-family-name) + data (js/Uint8Array. data)] + {:content {:data data + :name name + :type type} + :font-family family-name + :font-weight (cm/parse-font-weight base-name) + :font-style (cm/parse-font-style base-name) + :height-warning? false})))) + (defn process-upload "Given a seq of blobs and the team id, creates a ready-to-use fonts map with temporal ID's associated to each font entry. @@ -122,71 +244,16 @@ Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can wrap it in a `Blob` and hand it directly to `upload-blob-chunked` - without any intermediate client-side chunking." + without any intermediate client-side chunking. + + Files that cannot be read, carry an unknown signature, fail + opentype.js parsing, or fail entry preparation are collected and + reported with a `bad-font` notification instead of silently dropped, + so the user always gets feedback. The returned observable never + errors for per-file failures; it emits the map of successfully + processed fonts (possibly empty)." [blobs team-id] - (letfn [(prepare [{:keys [font type name data] :as params}] - (if font - ;; Font was parsed with opentype.js (ttf, otf, woff) - (let [family (or (.getEnglishName ^js font "preferredFamily") - (.getEnglishName ^js font "fontFamily")) - variant (or (.getEnglishName ^js font "preferredSubfamily") - (.getEnglishName ^js font "fontSubfamily")) - - ;; Vertical metrics determine the baseline in a text and the space between lines of - ;; text. For historical reasons, there are three pairs of ascender/descender - ;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating - ;; system and application a different set will be used to render text on the - ;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox - ;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If - ;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea - ;; table. On Windows, all browsers use the usWin metrics, but respect the - ;; useTypoMetrics setting and if set will use the OS/2 values. - - hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender)) - hhea-descender (abs (-> ^js font .-tables .-hhea .-descender)) - - win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent)) - win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent)) - - os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender)) - os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender)) - - ;; useTypoMetrics can be read from the 7th bit - f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7)) - - height-warning? (or (not= hhea-ascender win-ascent) - (not= hhea-descender win-descent) - (and f-selection (or - (not= hhea-ascender os2-ascent) - (not= hhea-descender os2-descent)))) - data (js/Uint8Array. data)] - {:content {:data data - :name name - :type type} - :font-family (or family "") - :font-weight (cm/parse-font-weight variant) - :font-style (cm/parse-font-style variant) - :variant-name variant - :height-warning? height-warning?}) - ;; Font could not be parsed (woff2), extract metadata from filename - (let [base-name (str/replace name #"\.[^.]+$" "") - ;; Strip known weight/style tokens and separators to derive family name - ;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold") - raw-family-name (-> base-name - (str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3") - (str/replace #"[-_\s]+" " ") - (str/trim)) - family-name (if (str/blank? raw-family-name) base-name raw-family-name) - data (js/Uint8Array. data)] - {:content {:data data - :name name - :type type} - :font-family family-name - :font-weight (cm/parse-font-weight base-name) - :font-style (cm/parse-font-style base-name) - :height-warning? false}))) - - (join [res {:keys [content] :as font}] + (letfn [(join [res {:keys [content] :as font}] (let [key-fn (juxt :font-family :font-weight :font-style) existing (d/seek #(= (key-fn font) (key-fn %)) (vals res))] (if existing @@ -206,60 +273,42 @@ (:data content)}) (dissoc :content))))))) - (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 - "117 124 124 117" "font/otf" - "0 1 0 0" "font/ttf" - "167 117 106 106" "font/woff" - "167 117 106 62" "font/woff2"))) - - (parse-font [{:keys [data type name] :as params}] - (if (= type "font/woff2") - ;; woff2 cannot be parsed by opentype.js, extract metadata from filename - (assoc params :font nil) - (try - (assoc params :font (ot/parse data)) - (catch :default _e - (log/warn :msg (str/fmt "skipping file %s, unsupported format" name)) - nil)))) - (read-blob [blob] - (->> (wa/read-file-as-array-buffer blob) - (rx/map (fn [data] - {:data data - :name (.-name blob) - :type (parse-mtype data)})) - (rx/catch (fn [] - (rx/of {:error (.-name blob)}))) - (rx/mapcat (fn [{:keys [type, error] :as font}] - (if (or type error) - (rx/of font) - (rx/empty))))))] + (let [blob-name (.-name blob)] + (->> (wa/read-file-as-array-buffer blob) + (rx/map (fn [data] + {:data data + :name blob-name + :type (parse-font-mtype data)})) + (rx/catch (fn [] + (rx/of {:error blob-name}))) + (rx/mapcat (fn [{:keys [type error] :as font}] + (if (or type error) + (rx/of font) + ;; Unknown signature: report it instead of silently dropping it. + (rx/of {:error blob-name})))))))] - (let [fonts (->> (rx/from blobs) - (rx/mapcat read-blob)) - errors (->> fonts - (rx/filter #(some? (:error %))) - (rx/reduce (fn [acc font] - (conj acc (str "'" (:error font) "'"))) - []))] - - (rx/subscribe errors - #(when - (not-empty %) - (st/emit! - (ntf/error - (if (> (count %) 1) - (tr "errors.bad-font-plural" (str/join ", " %)) - (tr "errors.bad-font" (first %))))))) - (->> fonts - (rx/filter #(nil? (:error %))) - (rx/map parse-font) - (rx/filter some?) - (rx/map prepare) - (rx/reduce join {}))))) + (->> (rx/from blobs) + (rx/mapcat read-blob) + (rx/map parse-font) + (rx/filter some?) + (rx/reduce (fn [acc item] + (if (:error item) + (update acc :errors conj (str "'" (:error item) "'")) + (try + (update acc :fonts #(join % (prepare-font item))) + (catch :default cause + (log/warn :msg "skipping font file" :cause cause :name (:name item)) + (update acc :errors conj (str "'" (:name item) "'")))))) + {:fonts {} :errors []}) + (rx/mapcat (fn [{:keys [fonts errors]}] + (when (seq errors) + (st/emit! + (ntf/error + (if (> (count errors) 1) + (tr "errors.bad-font-plural" (str/join ", " errors)) + (tr "errors.bad-font" (first errors)))))) + (rx/of fonts)))))) (defn- calculate-family-to-id-mapping [existing] diff --git a/frontend/src/app/main/data/modal.cljs b/frontend/src/app/main/data/modal.cljs index 458eba37ad..7db55ee4d7 100644 --- a/frontend/src/app/main/data/modal.cljs +++ b/frontend/src/app/main/data/modal.cljs @@ -17,6 +17,19 @@ ;; TODO: rename `:type` to `:name` +(defn- summarize-export-shapes + "Builds a compact body for the `:export-shapes` modal event. We know the + shape of these props here, so instead of dumping every export (each one + carrying its full shape, which can be megabytes) we emit counters: total + exports, distinct shapes and one count per export type." + [props] + (let [exports (:exports props)] + (merge + {:page (:name props) + :num-shapes (count (distinct (map :object-id exports))) + :num-exports (count exports)} + (frequencies (map :type exports))))) + (defn show ([props] (show (uuid/next) (:type props) props)) @@ -26,9 +39,17 @@ (ptk/reify ::show-modal ev/Event (-data [_] - (-> props - (dissoc :type) - (assoc :name type))) + (let [origin (:origin props) + data (if (= type :export-shapes) + (assoc (summarize-export-shapes props) :name type) + (-> props + (dissoc :type) + (assoc :name type)))] + ;; The origin is event metadata, not a prop: sending it as + ;; ::ev/origin makes `make-proto-event` put it on the event context + ;; (`:event-origin`); as a plain `:origin` it only stayed as a prop. + (cond-> (dissoc data :origin) + (some? origin) (assoc ::ev/origin origin)))) ptk/UpdateEvent (update [_ state] diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 6dd69e5c7c..7f08500144 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -14,6 +14,7 @@ [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.common :as-alias dc] + [app.main.data.event :as ev] [app.main.data.helpers :as dsh] [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] @@ -27,6 +28,8 @@ (declare ^:private run-persistence-task) (declare ^:private persist-commit) +(declare ^:private resume-persistence) +(declare ^:private slow-retry-cycle) (log/set-level! :warn) @@ -36,6 +39,25 @@ (def force-persist? #(= % ::force-persist)) +(def slow-retry-delay-ms + "Pause between the attempts of a queue whose backoff is spent: one attempt + per cycle, until it saves or the retry window closes." + 30000) + +(def retry-give-up-ms + "How long a queue that keeps failing on transport errors is sent again." + (* 24 60 60 1000)) + +(def slow-failure-ms + "An attempt that fails after this long may have kept the backend busy, + as a gateway timeout does, so it counts towards `max-slow-failures`." + (* 90 1000)) + +(def max-slow-failures + "Slow failures a queued commit may collect before the slow cycle stops + sending it. Fast failures, such as being offline, never count." + 3) + (def ^:private saving-stall-timeout-ms (* 5 60 1000)) (def ^:private saving-check-interval-ms 30000) (def ^:private save-wait-timeout-ms (* 2 60 1000)) @@ -87,6 +109,30 @@ ([timeout-ms] (rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms)))) +(defn- files-queued? + "True when the queue in `pstate` still holds changes of any of `file-ids`." + [{:keys [queue index]} file-ids] + (boolean (some #(contains? file-ids (get-in index [% :file-id])) queue))) + +(defn unsaved-changes? + "True when any of `file-ids` has changes the server does not have yet." + [state file-ids] + (files-queued? (:persistence state) file-ids)) + +(defn force-persist-files-and-wait + "Sends the pending changes and waits until no change of `file-ids` is + queued, saving fails, or `timeout-ms` passes. Never raises; check + `unsaved-changes?` afterwards." + [file-ids timeout-ms] + (rx/concat + (rx/of ::force-persist) + (->> (rx/from-atom refs/persistence {:emit-current-value? true}) + (rx/filter #(or (= :error (:status %)) + (not (files-queued? % file-ids)))) + (rx/take 1) + (rx/ignore) + (rx/timeout timeout-ms (rx/empty))))) + (defn- next-status "Refuses downgrades: a save in progress stays :saving, a failed save stays :error until persistence is resumed, and a retrying episode stays @@ -117,17 +163,31 @@ (#{:error :saved} status) (dissoc :run-id :last-progress-at :stall-reported)))))))) +(def ^:private busy-server-codes + "Server error codes for a database timeout that rolled the save back." + #{:statement-timeout :idle-in-transaction-timeout}) + +(defn- busy-server-error? + "True when the backend was too busy to apply the save: it refused it + (429) or a database timeout rolled it back. Resending it is safe." + [{:keys [type cause-type code]}] + (let [type (or cause-type type)] + (or (= :rate-limit type) + (and (= :server-error type) + (contains? busy-server-codes code))))) + (defn transient-error? "True when a save failure is worth retrying with backoff: a transient transport failure (from the shared `repo/retryable-types` set, which says - nothing about the edits themselves) or an unusable save response. - Everything else (auth, validation, state) is terminal and keeps the - `:error` path. Checked against both `:type` and `:cause-type` because + nothing about the edits themselves), a busy backend, or an unusable save + response. Everything else (auth, validation, state) is terminal and keeps + the `:error` path. Checked against both `:type` and `:cause-type` because `persistence-failed` wraps the original cause under `:type :persistence`." - [{:keys [type cause-type code]}] + [{:keys [type cause-type code] :as error}] (boolean (or (contains? rp/retryable-types type) (contains? rp/retryable-types cause-type) + (busy-server-error? error) (= :invalid-save-response code)))) (def ^:private retry-delays-ms @@ -142,6 +202,23 @@ save or on terminal failure." :persistence-reconnecting) +(defn- retry-window-open? + "True while a failing queue may still be sent again." + [pstate] + (let [since (:failing-since pstate)] + (or (nil? since) + (< (- (inst-ms (ct/now)) since) retry-give-up-ms)))) + +(defn- slow-retry-pending? + "True when the queue is halted by a transport failure that the slow retry + cycle keeps sending. A commit whose attempts keep failing slowly is not + sent again: each attempt may cost the backend the whole save." + [{:keys [status error slow-failures] :as pstate}] + (and (= :error status) + (transient-error? error) + (retry-window-open? pstate) + (< (or slow-failures 0) max-slow-failures))) + (defn- report-stalled-persistence [now] (ptk/reify ::report-stalled-persistence @@ -212,8 +289,9 @@ (throw (ex-info "invalid state" {}))))) (update :index dissoc commit-id) (assoc :last-progress-at (inst-ms (ct/now))) - (dissoc :stall-reported - :attempts :retry-token :retry-for))))))) + (dissoc :stall-reported :failing-since :recovering + :slow-failures :attempts :retry-token + :retry-for))))))) (defn- append-commit "Event used internally to append the current change to the @@ -243,6 +321,40 @@ (rx/of (update-status :saving) (run-persistence-task)))))))) +(defn- drop-file-commits + "Removes every queued commit belonging to `file-id`, together with its + index entry." + [pstate file-id] + (if-not file-id + pstate + (let [dropped (into #{} + (keep (fn [commit-id] + (when (= file-id (get-in pstate [:index commit-id :file-id])) + commit-id))) + (:queue pstate))] + (if (empty? dropped) + pstate + (-> pstate + (update :queue #(into (empty %) (remove dropped) %)) + (update :index #(apply dissoc % dropped))))))) + +(defn- notify-failure? + "False for a failed attempt of the slow cycle: it repeats a failure the + user was already told about." + [pstate] + (not (and (:recovering pstate) + (slow-retry-pending? pstate)))) + +(defn- save-failed-event + "Audit event for a save failure the user is told about, so failed saves + can be counted." + [{:keys [error queue] :as pstate}] + (ev/event {::ev/name "save-failed" + :cause-type (:cause-type error) + :code (:code error) + :retrying (slow-retry-pending? pstate) + :queued-commits (count queue)})) + (defn- persistence-failed [commit-id cause] (ptk/reify ::persistence-failed @@ -260,28 +372,44 @@ :commit-id commit-id :hint (ex-message cause) ::errors/handled? true)) + (update :failing-since d/nilv (inst-ms (ct/now))) (dissoc :run-id :last-progress-at :stall-reported :attempts :retry-token :retry-for)))))) ptk/WatchEvent - (watch [_ _ _] - ;; The terminal toast supersedes the reconnect notice; hide it - ;; explicitly instead of relying on the single-toast replacement. - (rx/of (ptk/data-event ::error cause) - (ntf/hide :tag reconnecting-tag))) + (watch [_ state stream] + (let [pstate (:persistence state)] + (rx/merge + ;; The terminal toast replaces the reconnect notice, hidden by tag. + (rx/of (ptk/data-event ::error cause) + (ntf/hide :tag reconnecting-tag)) + (if (notify-failure? pstate) + (rx/of (save-failed-event pstate)) + (rx/empty)) + ;; A transport failure that outlasts the backoff can still pass, so + ;; the queue keeps trying at a slow pace. + (if (and (not (:suspended pstate)) + (slow-retry-pending? pstate)) + (slow-retry-cycle stream) + (rx/empty))))) ptk/EffectEvent - (effect [_ _ _] - ;; Report without invoking global handlers that may reload the file or - ;; navigate away before the user can recover the retained changes. - (errors/flash-persistence cause)))) + (effect [_ state _] + ;; Notifies without the global handlers, which may reload the file or + ;; navigate away and lose the retained changes. + (let [pstate (:persistence state)] + (when (notify-failure? pstate) + (errors/flash-persistence cause :retrying (slow-retry-pending? pstate))))))) (defn- persistence-transient-failure "Transient save failure: the head commit stays queued and a retry is scheduled with backoff instead of parking the save in `:error`. Once the - budget (`retry-delays-ms`) is exhausted, the failure falls through to the - terminal `persistence-failed` path unchanged." - [commit-id cause] + budget (`retry-delays-ms`) is exhausted, or when the failed attempt was + one of the slow cycle, the failure falls through to the `persistence-failed` + path, which schedules the next slow attempt. While retries are suspended + nothing is scheduled: the episode waits for a file to be opened again. + `elapsed-ms` is how long the failed attempt took." + [commit-id cause elapsed-ms] (ptk/reify ::persistence-transient-failure ptk/UpdateEvent (update [_ state] @@ -296,13 +424,22 @@ :retry-token (uuid/next) :retry-for commit-id :last-progress-at (inst-ms (ct/now))) + (update :failing-since d/nilv (inst-ms (ct/now))) + (cond-> (>= elapsed-ms slow-failure-ms) + (update :slow-failures (fnil inc 0))) (dissoc :stall-reported)))))) ptk/WatchEvent (watch [_ state _] - (let [attempts (dm/get-in state [:persistence :attempts])] - (if (> attempts (count retry-delays-ms)) + (let [{:keys [attempts recovering suspended]} (:persistence state)] + (cond + (or recovering (> attempts (count retry-delays-ms))) (rx/of (persistence-failed commit-id cause)) + + suspended + (rx/empty) + + :else (rx/merge ;; One notice per episode: shown on the first attempt, ;; re-showing would only replace the identical toast. @@ -316,19 +453,6 @@ (->> (rx/timer delay-ms) (rx/map (fn [_] (persist-commit commit-id {:token token}))))))))))) -(defn- rotate-stalled-stamp - "Clears a previous attempt stamp when it is safe to resend: the commit is - the current retry head and its request is no longer in flight. A still - in-flight request is left alone — its own result drives the next step." - [state commit-id] - (let [commit (dm/get-in state [:persistence :index commit-id])] - (if (and (= :retrying (dm/get-in state [:persistence :status])) - (= commit-id (dm/get-in state [:persistence :retry-for])) - (::request-id commit) - (not (contains? @active-requests (::request-id commit)))) - (update-in state [:persistence :index commit-id] dissoc ::request-id) - state))) - (defn- commit-persisted [commit] (ptk/reify ::commit-persisted @@ -360,30 +484,29 @@ "Classifies what should happen with a queued commit before sending it. The attempt stamp and the send decision both read this, so a commit is only ever stamped with a request that is actually going to be sent." - [state commit-id request-id] + [state commit-id] (let [commit (dm/get-in state [:persistence :index commit-id])] (cond (= :error (dm/get-in state [:persistence :status])) :halted (nil? commit) :missing-commit (::acknowledged commit) :acknowledged (contains? @active-requests (::request-id commit)) :in-flight - (and (::request-id commit) - (not= request-id (::request-id commit))) :unknown-outcome (not (dm/get-in state [:permissions :can-edit])) :permission-denied :else :ready))) (defn- send-queued-commit "Sends one queued commit and maps its outcome to persistence events." [request-id session-id {:keys [id file-id file-revn file-vern changes features] :as commit}] - (let [params {:id file-id - :revn (max file-revn (get @revn-data file-id 0)) - :vern file-vern - :session-id session-id - :origin (:origin commit) - :created-at (:created-at commit) - :commit-id id - :changes (vec changes) - :features features}] + (let [sent-at (inst-ms (ct/now)) + params {:id file-id + :revn (max file-revn (get @revn-data file-id 0)) + :vern file-vern + :session-id session-id + :origin (:origin commit) + :created-at (:created-at commit) + :commit-id id + :changes (vec changes) + :features features}] ;; UI read-only mode does not invalidate already queued edits. (->> (update-file-request request-id params) ;; Fails an unanswered request as a transient network error, so the @@ -406,30 +529,29 @@ :code :invalid-save-response :file-id file-id}))))) (rx/catch (fn [cause] - (rx/of ((if (transient-error? (ex-data cause)) - persistence-transient-failure - persistence-failed) - id cause))))))) + (rx/of (if (transient-error? (ex-data cause)) + (persistence-transient-failure + id cause (- (inst-ms (ct/now)) sent-at)) + (persistence-failed id cause)))))))) (defn- persist-commit - "Sends the queued commit, stamping the attempt first. Inside a `:retrying` - episode a previous stamp is rotated when its request is no longer in - flight, so a retry resends the same commit instead of failing as - `:unknown-outcome`. Carries an optional `:token`: retry timers pass the - episode token, and a stale token (superseded episode) stays silent - instead of sending or failing." + "Sends the queued commit, stamping the attempt first. A commit whose + earlier request is no longer in flight is sent again: changes are + idempotent, so a save the backend already applied can be applied again. + Carries an optional `:token`: retry timers pass the episode token, and a + stale token (superseded episode) stays silent instead of sending or + failing." [commit-id & {:keys [token]}] (let [request-id (uuid/next)] (ptk/reify ::persist-commit ptk/UpdateEvent (update [_ state] (let [token-ok? (or (nil? token) - (= token (dm/get-in state [:persistence :retry-token]))) - state (if token-ok? (rotate-stalled-stamp state commit-id) state)] + (= token (dm/get-in state [:persistence :retry-token])))] (if (and token-ok? - (= :ready (attempt-state state commit-id request-id))) - ;; Record the attempt before starting I/O. An interrupted request - ;; may have reached the server and must not be replayed blindly. + (= :ready (attempt-state state commit-id))) + ;; Stamp the attempt before any I/O, so a request in flight is + ;; never sent twice in parallel. (assoc-in state [:persistence :index commit-id ::request-id] request-id) state))) @@ -446,16 +568,12 @@ (not= token (dm/get-in state [:persistence :retry-token]))) ;; Stale retry timer: its episode was superseded. Stay silent. (rx/empty) - (case (attempt-state state commit-id request-id) + (case (attempt-state state commit-id) :halted (rx/empty) :missing-commit (fail :missing-commit "A queued save has no change data") :acknowledged (rx/of (commit-persisted commit)) ;; The replacement runner listens for the original request's result. :in-flight (rx/empty) - ;; Fail-safe for anything outside a live retry episode (retry - ;; rotation in `update` already cleared the stamp when resending - ;; is safe). Keep the attempt stamp to prevent replay. - :unknown-outcome (fail :save-outcome-unknown "An interrupted save has an unknown outcome") :permission-denied (fail :save-permission-denied "Edit permission was lost before changes could be saved") :ready (send-queued-commit request-id (:session-id state) commit)))))))) @@ -484,7 +602,10 @@ (rx/filter #(= commit-id (:id %))) (rx/take 1) (rx/mapcat (fn [_] + ;; A save that lands shows the connection + ;; is back: the failure warning goes away. (rx/of (discard-commit commit-id) + (ntf/hide :tag errors/persistence-failed-tag) (run-persistence-task))))) (rx/of (persist-commit commit-id))) (rx/take-until stoper-s))) @@ -494,43 +615,186 @@ (ntf/hide :tag reconnecting-tag))))))) (defn- resume-persistence + "Starts the queue again. A `recovering` resume is an attempt of the slow + cycle, which sends once instead of starting a backoff episode." + ([] (resume-persistence false)) + ([recovering] + (ptk/reify ::resume-persistence + ptk/UpdateEvent + (update [_ state] + (update state :persistence + (fn [pstate] + (-> pstate + (dissoc :error :attempts :retry-token :retry-for) + (assoc :run-id (uuid/next) + :status :saving + :recovering recovering) + (update :last-progress-at d/nilv (inst-ms (ct/now))))))) + ptk/WatchEvent + (watch [_ _ _] + (rx/of (run-persistence-task)))))) + +(defn- slow-retry-cycle + "Resumes a failed queue after `slow-retry-delay-ms`, unless the queue is + resumed some other way, persistence restarts or the workspace closes." + [stream] + (let [stopper-s (rx/merge + (rx/filter (ptk/type? ::resume-persistence) stream) + (rx/filter (ptk/type? ::initialize-persistence) stream) + (rx/filter (ptk/type? ::dw/finalize-workspace) stream))] + (->> (rx/timer slow-retry-delay-ms) + (rx/map (fn [_] (resume-persistence true))) + (rx/take-until stopper-s)))) + +(defn- suspend-retries + "Stops retrying when the workspace closes. A pending backoff timer goes + stale, and no backoff or slow cycle is armed until a file is opened + again. Queued edits stay queued and a request in flight still lands." [] - (ptk/reify ::resume-persistence + (ptk/reify ::suspend-retries ptk/UpdateEvent (update [_ state] (update state :persistence - (fn [pstate] - (-> pstate - (dissoc :error :attempts :retry-token :retry-for) - (assoc :run-id (uuid/next) :status :saving) - (update :last-progress-at d/nilv (inst-ms (ct/now))))))) - ptk/WatchEvent - (watch [_ _ _] - (rx/of (run-persistence-task))))) + #(-> % + (assoc :suspended true) + (dissoc :retry-token)))))) -(defn- recover-persistence +(defn- restore-retries + "Lets failed saves be retried again once a file is opened." [] - (ptk/reify ::recover-persistence + (ptk/reify ::restore-retries + ptk/UpdateEvent + (update [_ state] + (update state :persistence dissoc :suspended)))) + +(defn- release-failed-file + "Drops the queued commits of the file whose save failed terminally. + + This runs when the user opens a file again, not at failure time. The + failure itself stays visible, because `wait-persisted-or-error` must keep + rejecting: callers use it to avoid snapshotting a file whose edits were + never saved. What must not survive is the halt. Left in place, the failed + commits sit at the head of the queue forever and, because the queue is + shared, they also block the pending edits of every other file." + [] + (ptk/reify ::release-failed-file + ptk/UpdateEvent + (update [_ state] + (let [commit-id (get-in state [:persistence :error :commit-id]) + file-id (get-in state [:persistence :index commit-id :file-id])] + (update state :persistence + #(-> % + (drop-file-commits file-id) + (dissoc :failing-since :slow-failures))))))) + +(defn- queue-needs-a-runner? + "True when the queue still holds commits that nobody is sending. + + The runner does not survive a subscription teardown, so both + initialization and recovery hit this. A halted queue is excluded: the + halt is lifted by `recover-persistence`, which has its own triggers. A + retrying episode is excluded too: its backoff timer owns the recovery, + or `recover-persistence` resumes it when retries were suspended." + [{:keys [queue status]}] + (and (seq queue) + (not= :error status) + (not= :retrying status))) + +(defn pending-file-changes + "Queued edits to restore into a freshly loaded file, in queue order. + Only edits of the loaded version belong in its data. A permanently + failed file is excluded because recovery releases its edits." + [{:keys [persistence]} {:keys [id vern]}] + (let [{:keys [queue index status error]} persistence + failed-file-id (get-in index [(:commit-id error) :file-id])] + (if (and (= :error status) + (= id failed-file-id) + (not (slow-retry-pending? persistence))) + [] + (into [] + (comp (map #(get index %)) + (filter #(and (= id (:file-id %)) + (= vern (:file-vern %)))) + (mapcat :redo-changes)) + queue)))) + +(defn- initialize-persistence-state + "Brings persistence to a coherent starting shape when the workspace + mounts. + + Deliberately leaves a halted or retrying queue alone. Lifting a halt is + `recover-persistence`'s job, and it waits for the user to open a file, so + doing it here would release edits the moment the view mounts rather than + when the user acts on them." + [] + (ptk/reify ::initialize-persistence-state ptk/WatchEvent (watch [_ state _] - (let [{:keys [queue index status error run-id]} (:persistence state) - commit (get index (peek queue))] + (let [{:keys [queue status run-id]} (:persistence state)] (cond - ;; A retrying episode owns its recovery through the backoff - ;; scheduler; resuming here would bypass the attempt budget. - (and (seq queue) - (not= status :retrying) - (or (not= status :error) - (and (= :save-permission-denied (:code error)) - (not (::request-id commit)) - (= (:file-id commit) (:current-file-id state)) - (dm/get-in state [:permissions :can-edit])))) + ;; A commit left queued by a previous session, with no runner. + (queue-needs-a-runner? (:persistence state)) (rx/of (resume-persistence)) + ;; Nothing queued and nothing in flight: there is nothing to save. (and (empty? queue) (not= status :error) (or run-id (#{:pending :saving} status))) - (rx/of (update-status :saved))))))) + (rx/of (update-status :saved)) + + :else + (rx/empty)))))) + +(defn- recover-persistence + "Lifts a halted queue when a file is opened again or the role changes. + + Leaving the workspace suspends retries (`suspend-retries`), so opening a + file (`opened-file?`) restores them, resends a retrying episode whose + backoff was cut short, and sends a failure the slow cycle still covers + (`slow-retry-pending?`) once more instead of dropping it. + + Every other terminal failure is permanent for that file: permission lost, + file gone, or changes the server refuses. None of them can succeed on a + resend, so opening a file releases them instead of replaying them. + Retaining them until here is deliberate: it is what keeps + `wait-persisted-or-error` rejecting, which is what stops + `workspace/versions.cljs` from building a snapshot out of unsaved edits. + + A lost permission is not a temporary condition. The backend raises + `:not-found` for it, and replaying the save to find that out routes the + user to the exception page." + [opened-file?] + (ptk/reify ::recover-persistence + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [queue status run-id suspended]} (:persistence state)] + (rx/concat + (if (and opened-file? suspended) + (rx/of (restore-retries)) + (rx/empty)) + (cond + ;; A live retry episode owns its recovery through the backoff + ;; scheduler; resuming here would bypass the attempt budget. + ;; A suspended one has no scheduler left. + (= :retrying status) + (if (and opened-file? suspended) + (rx/of (resume-persistence)) + (rx/empty)) + + (slow-retry-pending? (:persistence state)) + (rx/of (resume-persistence true)) + + (= :error status) + (rx/of (release-failed-file) (resume-persistence)) + + (queue-needs-a-runner? (:persistence state)) + (rx/of (resume-persistence)) + + (and (empty? queue) (or run-id (#{:pending :saving} status))) + (rx/of (update-status :saved)) + + :else + (rx/empty))))))) (def ^:private xf-mapcat-undo (mapcat :undo-changes)) @@ -552,13 +816,14 @@ (defn- resume-on-online "Re-enters the runner for a retrying episode when the browser reports connectivity back, instead of waiting out the backoff. Terminal failures - stay terminal: only a live `:retrying` episode resumes." + stay terminal: only a live `:retrying` episode resumes, never one + suspended outside the workspace." [] (ptk/reify ::resume-on-online ptk/WatchEvent (watch [_ state _] - (let [{:keys [status queue]} (:persistence state)] - (if (and (seq queue) (= :retrying status)) + (let [{:keys [status queue suspended]} (:persistence state)] + (if (and (seq queue) (= :retrying status) (not suspended)) (rx/of (run-persistence-task)) (rx/empty)))))) @@ -573,6 +838,17 @@ (swap! st/ongoing-tasks conj :persistence) (swap! st/ongoing-tasks disj :persistence))))) +(defn leave-signals + "Emits when the tab is hidden or the window loses focus: another tab or + app, a plugin iframe, a closed laptop. Empty outside the browser." + [] + (if (and (exists? js/window) (exists? js/document)) + (rx/merge + (->> (rx/from-event js/document "visibilitychange") + (rx/filter #(= "hidden" (.-visibilityState js/document)))) + (rx/from-event js/window "blur")) + (rx/empty))) + (defn initialize-persistence [] (ptk/reify ::initialize-persistence @@ -601,12 +877,17 @@ (rx/filter #(= % ::force-persist))))] (rx/merge - (rx/of (recover-persistence)) + (rx/of (initialize-persistence-state)) + + (->> stream + (rx/filter (ptk/type? ::dw/finalize-workspace)) + (rx/map (fn [_] (suspend-retries))) + (rx/take-until stoper-s)) (->> stream (rx/filter #(or (ptk/type? ::dc/change-team-role %) (ptk/type? ::dw/workspace-initialized %))) - (rx/map (fn [_] (recover-persistence))) + (rx/map #(recover-persistence (ptk/type? ::dw/workspace-initialized %))) (rx/take-until stoper-s)) (->> (rx/interval saving-check-interval-ms) @@ -626,15 +907,23 @@ (rx/map #(ptk/data-event ::persistence-notification)) (rx/take-until stoper-s)) + ;; Sends the buffered edits when the user leaves the tab or window. + (->> (leave-signals) + (rx/filter (fn [_] @buffered?)) + (rx/map (fn [_] ::force-persist)) + (rx/take-until stoper-s)) + ;; Syncs the leave-page warning on every local commit and on every - ;; queue change (append after a flush, discard after a save). + ;; queue change (append after a flush, discard after a save, + ;; release of a failed file). (->> (rx/merge (rx/of false) (->> local-commits-s (rx/map (fn [_] (vreset! buffered? true)))) (->> stream (rx/filter #(or (ptk/type? ::append-commit %) - (ptk/type? ::discard-commit %))) + (ptk/type? ::discard-commit %) + (ptk/type? ::release-failed-file %))) (rx/map (fn [_] @buffered?)))) (rx/map sync-unload-guard) (rx/take-until stoper-s)) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 6ed22e5fb1..9a045d4995 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -17,6 +17,37 @@ (log/set-level! :warn) +;; Keyboard layout map shared by the shortcut recorder +;; (`app.main.ui.shortcuts`) and the Mousetrap dispatcher: punctuation +;; resolves by physical position through this map, so recording and +;; dispatch use the same token. `nil` until `getLayoutMap` resolves (or +;; when the browser has no keyboard API); both sides then fall back to +;; the typed glyph, which keeps them consistent with and without a map. +(defonce layout-map (atom nil)) + +(defn layout-map-supported? + "Synchronous keyboard-layout API probe: true when the browser exposes + navigator.keyboard.getLayoutMap (Chromium). Punctuation then resolves + by physical position; without it both sides fall back to the typed + glyph, so Shift+punctuation shortcuts may not fire." + ([] (layout-map-supported? + (when (exists? js/navigator) js/navigator))) + ([navigator] + (let [keyboard (when (some? navigator) (.-keyboard ^js navigator))] + (boolean + (and (some? keyboard) + (exists? (.-getLayoutMap ^js keyboard))))))) + +(let [navigator (when (exists? js/navigator) js/navigator)] + (when (layout-map-supported? navigator) + (-> (.getLayoutMap ^js (.-keyboard ^js navigator)) + (.then (fn [m] + (cljs.core/reset! layout-map m) + (.setLayoutMap mousetrap m)) + (fn [_] + (cljs.core/reset! layout-map nil) + (.setLayoutMap mousetrap nil)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -156,8 +187,11 @@ shortcuts (reduce-kv (fn [acc sc-key new-command] + ;; A non-customizable shortcut ignores overrides stored before it + ;; became so: the settings page no longer offers to reset them. (if (and (contains? acc sc-key) - (not (:disabled (get acc sc-key)))) + (not (:disabled (get acc sc-key))) + (not (false? (:customizable (get acc sc-key))))) (-> acc (assoc-in [sc-key :original-command] (get-in acc [sc-key :command])) (assoc-in [sc-key :command] new-command) 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 22e07efc66..f002d49bed 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -1,170 +1,357 @@ +;; 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.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] + [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 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 :as V]] - [(keyword k) (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)) -;; 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- 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)})) -(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- 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}))))) - ;; 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)) +(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 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))) + :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)}))) - ;; Extract the atom value - get-result - (fn [] @output)] - #js {:onResolve on-resolve - :onError on-error - :getResult get-result})) +(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)}))) -(defn clj->token->tokenscript-token - "Convert penpot token into a format that tokenscript can handle." +(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." + [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->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 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/viewer/shortcuts.cljs b/frontend/src/app/main/data/viewer/shortcuts.cljs index 23f71e9b57..83878c9682 100644 --- a/frontend/src/app/main/data/viewer/shortcuts.cljs +++ b/frontend/src/app/main/data/viewer/shortcuts.cljs @@ -17,7 +17,8 @@ (def shortcuts {:increase-zoom {:tooltip "+" :label (fn [] (tr "shortcuts.increase-zoom")) - :command "+" + :command ["+" "="] + :show-command "+" :subsections [:zoom-viewer] :section [:viewer] :fn #(st/emit! dv/increase-zoom)} diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index df97941fe2..83777b3383 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -9,6 +9,7 @@ [app.common.attrs :as attrs] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.changes :as cpc] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] @@ -313,6 +314,22 @@ :file-id file-id :team-id team-id))))))) +(defn- restore-pending-changes + "Applies the edits still waiting to be saved to a freshly loaded `file`. + An edit that no longer applies to the loaded data leaves the file as the + server sent it; the save queue keeps the edits either way." + [state file] + (let [changes (dps/pending-file-changes state file)] + (if (empty? changes) + file + (try + (update file :data cpc/process-changes changes false) + (catch :default cause + (log/wrn :hint "unable to restore pending changes" + :file-id (dm/str (:id file)) + :cause cause) + file))))) + (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] (ptk/reify ::bundle-fetched @@ -322,9 +339,10 @@ ptk/UpdateEvent (update [_ state] - (-> state - (assoc :thumbnails (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))) - (update :files assoc file-id file))))) + (let [file (restore-pending-changes state file)] + (-> state + (assoc :thumbnails (d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))) + (update :files assoc file-id file)))))) (defn zoom-to-frame [] diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 557747d080..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] @@ -69,6 +70,43 @@ [potok.v2.core :as ptk] [promesa.core :as p])) +(defn- advance-shape + [file libraries page level-delta head-id objects shape] + (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) + container (ctn/make-container page :page) + ;; The head becomes a root copy, which cannot carry a swap slot, so + ;; strip inherited slots only for it. Nested sub-heads keep them so + ;; they still pair with the children of the new near main. + head? (= (:id shape) head-id) + new-touched (ctf/get-touched-from-ref-chain-until-target-ref + container libraries shape new-shape-ref :remove-slots? head?)] + (cond-> objects + (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) + (update (:id shape) assoc :shape-ref new-shape-ref :touched new-touched)))) + +(defn- advance-copy + [file libraries page objects shape] + (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) + (let [level-delta (if (nil? (ctk/get-swap-slot shape)) + (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) + 0)] + (if (pos? level-delta) + (reduce (partial advance-shape file libraries page level-delta (:id shape)) + objects + (cfh/get-children-with-self objects (:id shape))) + objects)) + objects)) + +(defn advance-copies + "When copying an instance that is nested inside another one, we need to + advance the shape refs to one or more levels of remote mains." + [file libraries page selected data] + (let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] + (update data :objects + #(reduce (partial advance-copy file libraries page) + % + heads)))) + (defn copy-selected [] (letfn [(sort-selected [state data] @@ -150,39 +188,13 @@ (let [frame (get objects parent-frame-id)] (gsh/translate-to-frame shape frame)))) - ;; When copying an instance that is nested inside another one, we need to - ;; advance the shape refs to one or more levels of remote mains. - (advance-copies [state selected data] - (let [file (dsh/lookup-file state) - libraries (:files state) - ;; FIXME - page (dsh/lookup-page state) - heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] - (update data :objects - #(reduce (partial advance-copy file libraries page) - % - heads)))) - - (advance-copy [file libraries page objects shape] - (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) - (let [level-delta (if (nil? (ctk/get-swap-slot shape)) - (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) - 0)] - (if (pos? level-delta) - (reduce (partial advance-shape file libraries page level-delta) - objects - (cfh/get-children-with-self objects (:id shape))) - objects)) - objects)) - - (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) - container (ctn/make-container page :page) - new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)] - (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (-> (assoc-in [(:id shape) :shape-ref] new-shape-ref) - (assoc-in [(:id shape) :touched] new-touched))))) + (advance-copies-from-state [state selected data] + (advance-copies (dsh/lookup-file state) + (:files state) + ;; FIXME + (dsh/lookup-page state) + selected + data)) (on-copy-error [error] (js/console.error "clipboard blocked:" error) @@ -209,14 +221,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)))] @@ -235,7 +251,7 @@ (rx/merge-map (partial prepare-object objects frame-id)) (rx/reduce (partial collect-data state) initial) (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) + (rx/map (partial advance-copies-from-state state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map #(wapi/create-blob % "text/plain")) (rx/subs! resolve reject))))] @@ -250,7 +266,7 @@ (rx/merge-map (partial prepare-object objects frame-id)) (rx/reduce (partial collect-data state) initial) (rx/map (partial sort-selected state)) - (rx/map (partial advance-copies state selected)) + (rx/map (partial advance-copies-from-state state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map clipboard/to-clipboard) (rx/catch on-copy-error) @@ -377,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}) @@ -584,6 +603,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 +1095,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 +1131,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-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/grid_layout/shortcuts.cljs b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs index 1e234dc62d..de5349fca3 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs @@ -55,7 +55,8 @@ :increase-zoom {:tooltip "+" :label (fn [] (tr "shortcuts.increase-zoom")) - :command "+" + :command ["+" "="] + :show-command "+" :section [:workspace] :fn #(st/emit! (dw/increase-zoom nil))} diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 2113f76ea1..b84faf4356 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -531,7 +531,8 @@ (-> (pcb/empty-changes it nil) (cll/generate-duplicate-component library component-id new-component-id))] (rx/of - (ptk/data-event :layout/update {:ids [(:id main-instance)]}) + (ptk/data-event :layout/update {:page-id (:main-instance-page (get-in library [:data :components component-id])) + :ids [(:id main-instance)]}) (dch/commit-changes changes))))))) (defn delete-component 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/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 5386fef3df..0e4bb0a5ce 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -744,8 +744,9 @@ #_:clj-kondo/ignore (defn set-wasm-modifiers [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis - subtree-ids-by-id selection-rect-cache] - :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil} + subtree-ids-by-id selection-rect-cache interactive?] + :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil + interactive? true} :as params}] (let [modif-tree (without-nil-ids modif-tree)] (ptk/reify ::set-wasm-modifiers @@ -764,8 +765,10 @@ ;; the renderer into fast + atlas-backdrop mode so the live ;; preview is cheap, tiles never appear sequentially and the main ;; thread is not blocked. The pair is closed in - ;; `clear-local-transform`. - (ensure-interactive-transform-start!) + ;; `clear-local-transform`. Previews outside a gesture pass + ;; `:interactive? false` to keep full quality rendering. + (when interactive? + (ensure-interactive-transform-start!)) (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index c1d2269383..21f1c87960 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -59,7 +59,8 @@ :add-node {:tooltip (ds/shift "+") :label (fn [] (tr "shortcuts.add-node")) - :command "shift++" + :command ["shift++" "shift+="] + :show-command "shift++" :subsections [:path-editor] :section [:workspace] :fn #(st/emit! (drp/add-node))} @@ -117,8 +118,7 @@ :snap-nodes {:tooltip (ds/meta "'") :label (fn [] (tr "shortcuts.snap-nodes")) - ;;https://github.com/ccampbell/mousetrap/issues/85 - :command [(ds/c-mod "'") (ds/c-mod "219")] + :command (ds/c-mod "'") :subsections [:path-editor] :section [:workspace] :fn #(st/emit! (drp/toggle-snap))} @@ -127,6 +127,7 @@ :label (fn [] (tr "shortcuts.copy")) :command (ds/c-mod "c") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/copy-selected-nodes))} :cut {:tooltip (ds/meta "X") @@ -139,6 +140,7 @@ :label (fn [] (tr "shortcuts.paste")) :command (ds/c-mod "v") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/paste-nodes))} :duplicate {:tooltip (ds/meta "D") @@ -176,6 +178,7 @@ :label (fn [] (tr "shortcuts.escape")) :command ["escape" "v"] :section [:workspace] + :customizable false :fn #(st/emit! (esc-pressed))} ;; Reuses the `:start-editing` key (instead of adding "enter" to @@ -207,7 +210,8 @@ :increase-zoom {:tooltip "+" :label (fn [] (tr "shortcuts.increase-zoom")) - :command "+" + :command ["+" "="] + :show-command "+" :section [:workspace] :fn #(st/emit! (dw/increase-zoom nil))} diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 6956305804..380ab0b0d8 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -20,6 +20,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] @@ -95,6 +96,7 @@ :command (ds/c-mod "c") :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! (dw/copy-selected))} :copy-link {:tooltip (ds/shift (ds/alt "C")) @@ -179,6 +181,7 @@ :command "escape" :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))} :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] @@ -568,8 +571,7 @@ :toggle-guides {:tooltip (ds/meta "'") :label (fn [] (tr "shortcuts.toggle-guides")) - ;;https://github.com/ccampbell/mousetrap/issues/85 - :command [(ds/c-mod "'") (ds/c-mod "219")] + :command (ds/c-mod "'") :show-command (ds/c-mod "'") :subsections [:main-menu] :section [:workspace] @@ -620,8 +622,7 @@ :toggle-snap-guides {:tooltip (ds/meta-shift "'") :label (fn [] (tr "shortcuts.toggle-snap-guides")) - ;;https://github.com/ccampbell/mousetrap/issues/85 - :command [(ds/c-mod "shift+'") (ds/c-mod "shift+219")] + :command (ds/c-mod "shift+'") :show-command (ds/c-mod "shift+'") :subsections [:main-menu] :section [:workspace] @@ -656,7 +657,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")))} :open-branches {:tooltip (ds/meta-alt "B") :label (fn [] (tr "shortcuts.open-branches")) @@ -687,7 +689,8 @@ :hide-ui {:tooltip "\\" :label (fn [] (tr "shortcuts.hide-ui")) - :command "\\" + :command ["\\" "alt+\\" "alt+º"] + :show-command "\\" :subsections [:panels :basics] :section [:workspace :basics] :fn #(st/emit! (toggle-layout-flag :hide-ui))} @@ -724,7 +727,8 @@ :zoom-selected {:tooltip (ds/shift "2") :label (fn [] (tr "shortcuts.zoom-selected")) - :command ["shift+2" "shift+num2" "@" "\""] + :command ["shift+2" "shift+num2" "shift+'" "shift+\""] + :show-command "shift+2" :subsections [:zoom-workspace] :section [:workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index faa68099fc..bc077f92c4 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -17,10 +17,12 @@ [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] [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] @@ -1468,12 +1470,72 @@ :name name :finalize? finalize?))) +(defn- layout-item + "The ancestor of `shape` (or `shape` itself) that is a direct child of its board." + [objects shape] + (loop [item shape] + (let [parent-id (:parent-id item)] + (if (or (= parent-id (:frame-id shape)) + (nil? (get objects parent-id))) + item + (recur (get objects parent-id)))))) + +(defn- v3-sync-text-layout + "Previews an auto-grow text's live size in its layout, so a fit-content parent + follows it while typing. Geometry is committed on finalize." + [id] + (ptk/reify ::v3-sync-text-layout + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shape (get objects id) + preview? (and (cfh/text-shape? shape) + (not= :fixed (:grow-type shape)) + (ctl/any-layout-descent? objects shape) + (not (ctl/position-absolute? (layout-item objects shape)))) + size (when preview? + (dwwt/get-wasm-text-new-size shape)) + previous (or (get-in state [::layout-preview id]) + (select-keys (:selrect shape) [:width :height]))] + ;; Modifiers are costly, so skip keystrokes that keep the size. + (if (and (some? size) + (not (and (mth/close? (:width size) (:width previous)) + (mth/close? (:height size) (:height previous))))) + (rx/of #(assoc-in % [::layout-preview id] size) + (dwm/set-wasm-modifiers (dwwt/resize-wasm-text-modifiers shape) + :interactive? false)) + (rx/empty)))))) + +(defn v3-stop-text-layout-preview + "Drops the layout preview of the text `id` and leaves interactive render mode." + [id] + (ptk/reify ::v3-stop-text-layout-preview + ptk/WatchEvent + (watch [_ state _] + (if (contains? (get state ::layout-preview) id) + (rx/of #(update % ::layout-preview dissoc id) + (dwm/clear-local-transform)) + (rx/empty))))) + +(defn v3-update-editor-content + "Commits `content` from the WASM editor to the text `shape-id` and, while + editing, previews its new size in the layout." + [shape-id content & {:keys [finalize?]}] + (let [update-event (editor-content-update shape-id content :finalize? finalize?)] + (if finalize? + update-event + (ptk/reify ::v3-update-editor-content + ptk/WatchEvent + (watch [_ _ _] + (rx/of update-event + (v3-sync-text-layout shape-id))))))) + (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)] - (editor-content-update shape-id content :finalize? finalize?))) + (v3-update-editor-content shape-id content :finalize? finalize?))) (defn v3-apply-pending-caret-styles "Event saving an insertion made at `before` that consumed the pending caret style: @@ -1483,7 +1545,7 @@ ;; 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-update-editor-content shape-id content) (v3-sync-editor-content)))) (defn- sync-editor-content-stream @@ -1503,13 +1565,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)}))))) @@ -1552,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." @@ -1571,28 +1634,65 @@ [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 + "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- 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." - [{:keys [objects selected file-id team-id]}] + 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) - {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) 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] - {: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 @@ -1609,17 +1709,70 @@ (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))] {:penpot? false :fragment fragment})))) +(defn- target-geometry + "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) + 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-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-span-fills content pasted start geometry target)] + (wasm.api/set-shape-text-content (:id shape) content) + content) + content))) + +(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-fills + 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-span-fills 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)) @@ -1636,7 +1789,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?)] - (editor-content-update shape-id content) + (let [shape (dsh/lookup-shape state shape-id)] + (v3-update-editor-content shape-id (move-pasted-fills shape content paste start))) (v3-sync-editor-content))) pending? @@ -1653,7 +1807,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/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 13bfd42689..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/tokenscript-symbols->penpot-unit 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/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index f8b276cdf3..6b22169719 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.tokens.library-edit (:require [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.files.tokens :as cfo] @@ -48,6 +49,28 @@ ;; Helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn editable-tokens-status? + "True when file-id is the current file. Its activation status is editable + even when its tokens come from an external source." + [state file-id] + (= file-id (:current-file-id state))) + +(defn editable-tokens? + "True when file-id is the current file and uses its own tokens source. + Use this predicate when the file may be a connected library." + [state file-id] + (and (editable-tokens-status? state file-id) + (cfo/editable-tokens? (dsh/lookup-file-data state file-id)))) + +(defn- check-editable-tokens! + "Throws when the tokens of the current file come from an external library, + so they can't be modified from this file." + [state] + (when-not (editable-tokens? state (:current-file-id state)) + (ex/raise :type :assertion + :code :tokens-not-editable + :hint "cannot modify the tokens of an external library"))) + ;; TODO HYMA: Copied over from workspace.cljs (defn update-shape [id attrs] @@ -268,6 +291,7 @@ (ptk/reify ::create-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state)] (if (and tokens-lib (ctob/get-theme tokens-lib (ctob/get-id token-theme))) @@ -286,6 +310,7 @@ (ptk/reify ::update-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state)] (if (and (not= id (ctob/get-id token-theme)) @@ -322,7 +347,7 @@ (ptk/reify ::toggle-token-theme-active ptk/WatchEvent (watch [it state _] - (let [data (dsh/lookup-tokens-source-data state) + (let [data (dsh/lookup-file-data state) tokens-status (dsh/lookup-tokens-status state) tokens-lib (dsh/lookup-tokens-lib state) changes (-> (pcb/empty-changes it) @@ -338,6 +363,7 @@ (ptk/reify ::delete-token-theme ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -352,6 +378,7 @@ (ptk/reify ::create-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -366,6 +393,7 @@ (ptk/reify ::update-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -377,7 +405,8 @@ [set-group-path set-group-fname] (ptk/reify ::rename-token-set-group ptk/WatchEvent - (watch [it _state _] + (watch [it state _] + (check-editable-tokens! state) (let [changes (-> (pcb/empty-changes it) (pcb/rename-token-set-group set-group-path set-group-fname))] (rx/of @@ -390,6 +419,7 @@ (ptk/reify ::duplicate-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state) suffix (tr "workspace.tokens.duplicate-suffix")] @@ -458,6 +488,7 @@ (ptk/reify ::import-tokens-lib ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) status (cfo/make-tokens-status-from-lib lib) changes (-> (pcb/empty-changes it) @@ -486,6 +517,7 @@ (ptk/reify ::delete-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -498,6 +530,7 @@ (ptk/reify ::delete-token-set-group ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (-> (pcb/empty-changes it) (pcb/with-library-data data) @@ -528,6 +561,7 @@ (ptk/reify ::drop-token-set-group ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (try (when-let [changes (clo/generate-move-token-set-group (pcb/empty-changes it) (dsh/lookup-tokens-lib state) drop-opts)] (rx/of @@ -544,6 +578,7 @@ (ptk/reify ::drop-token-set ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (try (let [tokens-lib (dsh/lookup-tokens-lib state) changes (-> (pcb/empty-changes it) @@ -559,6 +594,7 @@ (ptk/reify ::create-token-and-set ptk/WatchEvent (watch [_ state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) @@ -594,6 +630,7 @@ (ptk/reify ::create-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (if-let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state))] @@ -621,6 +658,7 @@ (ptk/reify ::bulk-create-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (lookup-token-set state set-id) data (dsh/lookup-file-data state) changes (reduce (fn [changes token-id] @@ -650,6 +688,7 @@ (ptk/reify ::update-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state)) @@ -678,6 +717,7 @@ (ptk/reify ::bulk-update-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [token-set (if set-id (lookup-token-set state set-id) (lookup-token-set state)) @@ -708,6 +748,7 @@ (ptk/reify ::delete-token ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) tokens-lib (dsh/lookup-tokens-lib state) token-set (if set-id @@ -731,6 +772,7 @@ (ptk/reify ::bulk-delete-tokens ptk/WatchEvent (watch [it state _] + (check-editable-tokens! state) (let [data (dsh/lookup-file-data state) changes (reduce (fn [changes token-id] (pcb/set-token changes set-id token-id nil)) @@ -746,6 +788,7 @@ (ptk/reify ::duplicate-token ptk/WatchEvent (watch [_ state _] + (check-editable-tokens! state) (when-let [token-set (lookup-token-set state)] (when-let [tokens-lib (dsh/lookup-tokens-lib state)] (when-let [token (ctob/get-token tokens-lib diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 04472fd046..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,8 +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 ts/tokenscript-symbols->penpot-unit)))) + (rx/of (ts/resolve-tokens tokens-tree)) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index e5bcaa1c8a..48b74d7023 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] + [app.common.geom.image-crop :as gic] [app.common.geom.matrix :as gmt] [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] @@ -213,10 +214,29 @@ ;; Resize origin point given the selected handler selrect (dm/get-prop shape :selrect) handler-origin (get-handler-resize-origin selrect handler) + selrect-start (gpt/point (dm/get-prop selrect :x) (dm/get-prop selrect :y)) + + ;; Image crop (mod key): the shape is a window over a + ;; picture that keeps its size and position. Dragging an + ;; edge past the fixed one moves the window to the other + ;; side instead of flipping it. + crop-origin (if ^boolean center? + (gpt/point (/ width 2) (/ height 2)) + (gpt/subtract handler-origin selrect-start)) + [crop-dx crop-w] (gic/crop-axis width (dm/get-prop crop-origin :x) (dm/get-prop scalev :x)) + [crop-dy crop-h] (gic/crop-axis height (dm/get-prop crop-origin :y) (dm/get-prop scalev :y)) + + scalev (if ^boolean bounds-resize? + (gpt/point (/ crop-w width) (/ crop-h height)) + scalev) ;; If we want resize from center, displace the shape ;; so it is still centered after resize. - displacement (when ^boolean center? + displacement (cond + ^boolean bounds-resize? + (gpt/transform (gpt/point crop-dx crop-dy) shape-transform) + + ^boolean center? (-> shape-center (gpt/subtract handler-origin) (gpt/multiply scalev) @@ -225,7 +245,10 @@ (gpt/multiply (gpt/point -1 -1)) (gpt/transform shape-transform))) - resize-origin (gmt/transform-point-center handler-origin shape-center shape-transform) + resize-origin (gmt/transform-point-center + (if ^boolean bounds-resize? selrect-start handler-origin) + shape-center + shape-transform) resize-origin (if (some? displacement) (gpt/add resize-origin displacement) resize-origin) @@ -243,57 +266,23 @@ change-height? (not (mth/close? (dm/get-prop scalev :y) 1)) - ;; Calculate independent image bounds resize transform - sx (dm/get-prop scalev :x) - sy (dm/get-prop scalev :y) - w-new (* width sx) - h-new (* height sy) - - bounds-resize? (and ^boolean bounds-resize? - (pos? w-new) - (pos? h-new)) - - [dx dy] (if ^boolean center? - [(/ (* width (- 1.0 sx)) 2.0) - (/ (* height (- 1.0 sy)) 2.0)] - [(case handler - (:left :bottom-left :top-left) (* width (- 1.0 sx)) - 0.0) - (case handler - (:top :top-left :top-right) (* height (- 1.0 sy)) - 0.0)]) + ;; Keep the picture in place inside the new crop window + update-image + (fn [img-fill] + (update img-fill :transform gic/update-image-transform + width height crop-dx crop-dy crop-w crop-h)) new-fills - (when (and bounds-resize? (seq (:fills shape))) + (when (and ^boolean bounds-resize? (seq (:fills shape))) (mapv (fn [fill] - (if-let [img-fill (:fill-image fill)] - (let [tf (get img-fill :transform) - nx0 (get tf :x 0.0) - ny0 (get tf :y 0.0) - nw0 (get tf :width 1.0) - nh0 (get tf :height 1.0) - nx' (/ (- (* nx0 width) dx) w-new) - ny' (/ (- (* ny0 height) dy) h-new) - nw' (/ nw0 sx) - nh' (/ nh0 sy)] - (assoc-in fill [:fill-image :transform] - {:x nx' :y ny' :width nw' :height nh'})) - fill)) + (cond-> fill + (some? (:fill-image fill)) + (update :fill-image update-image))) (:fills shape))) new-fill-image - (when (and bounds-resize? (some? (:fill-image shape))) - (let [img-fill (:fill-image shape) - tf (get img-fill :transform) - nx0 (get tf :x 0.0) - ny0 (get tf :y 0.0) - nw0 (get tf :width 1.0) - nh0 (get tf :height 1.0) - nx' (/ (- (* nx0 width) dx) w-new) - ny' (/ (- (* ny0 height) dy) h-new) - nw' (/ nw0 sx) - nh' (/ nh0 sy)] - (assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))] + (when (and ^boolean bounds-resize? (some? (:fill-image shape))) + (update-image (:fill-image shape)))] (cond-> (ctm/empty) (some? displacement) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 2f578f77df..f4bee091d1 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -100,6 +100,40 @@ (assoc ::instance cause) (assoc ::trace (.-stack cause))))) +;; Events in flight when each exception was caught, as report lines +(defonce ^:private in-flight-by-cause (js/WeakMap.)) + +;; Events kept from each end of a long in-flight list +(def ^:private in-flight-edge-size 10) + +(defn- in-flight-lines + "Report lines for the in-flight `events`, outermost first; a long list + keeps only both ends." + [events] + (let [total (count events) + size in-flight-edge-size] + (if (<= total (* 2 size)) + (mapv ptk/repr-event events) + (-> [] + (into (map ptk/repr-event) (take size events)) + (conj (str "... " (- total (* 2 size)) " more ...")) + (into (map ptk/repr-event) (take-last size events)))))) + +(defn- record-in-flight! + "Keeps the events the store is processing for the report of `cause`. + Runs synchronously on the error path: when an event's `update`, `effect` + or synchronous `watch` throws, the last line is that event. When a + `watch` pipeline fails on a value from the store stream, the store has + already finished that value, so the lines show the events that emitted + it, not the event that owns the pipeline." + [cause] + (try + (when (ex/exception? cause) + (when-let [events (not-empty (ptk/in-flight-events st/state))] + (.set in-flight-by-cause cause (in-flight-lines events)))) + (catch :default _ + nil))) + (defn on-error "A general purpose error handler. @@ -114,6 +148,7 @@ (ex/print-throwable error)) (do (vreset! handling-error? true) + (record-in-flight! (if (map? error) (::instance error) error)) (try (if (map? error) (ptk/handle-error error) @@ -157,7 +192,7 @@ (println "Context:") (println "--------------------") (println "Timestamp:" (ct/format-inst (ct/now) :rfc1123)) - (println "Hint: " (or (:hint data) (ex-message cause) "--")) + (println "Hint: " (or (ex/get-hint cause) "--")) (println "Prof ID: " (str (or profile-id "--"))) (println "Team ID: " (str (or team-id "--"))) (when-let [file-id (or (:file-id data) file-id)] @@ -166,8 +201,9 @@ (println "HREF: " (rt/get-current-href))))) (defn- generate-full-report - "Complete report: context, formatted throwable (including `ex-data`) and the - last events." + "Complete report: context, formatted throwable (including `ex-data`), the + events in flight when the error was caught (when recorded) and the last + events." [cause] (with-out-str (print (report-context cause)) @@ -177,6 +213,12 @@ (ex/format-throwable cause)) (println) + (when-let [lines (.get in-flight-by-cause cause)] + (println "Events in flight when the error was caught (outermost first):") + (println "--------------------") + (run! println lines) + (println)) + (println "Last events:") (println "--------------------") (println (st/format-last-events)) @@ -202,9 +244,9 @@ "Build the report string for `cause`. `:format` selects the payload: `:full` (default) includes the formatted - throwable and the last events; `:compact` keeps only the context and the - error type/code/uri, for environment failures. The option is accepted both - as keyword arguments and as a trailing map." + throwable, the in-flight and the last events; `:compact` keeps only the + context and the error type/code/uri, for environment failures. The option + is accepted both as keyword arguments and as a trailing map." [cause & {:keys [format] :or {format :full}}] (try (case format @@ -392,6 +434,24 @@ hint occurrences)))) +(defonce ^:private update-loop-warned-at + (volatile! 0)) + +(defn on-update-loop + "Handler for the rumext update scheduler trips: logs the throttled + update loop with the events being processed, at most once per + `report-window-ms`. Runs inside store watchers and effect bodies, so it + never throws." + [_] + (try + (let [now (inst-ms (ct/now))] + (when (>= (- now @update-loop-warned-at) report-window-ms) + (vreset! update-loop-warned-at now) + (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" + (into-array (map ptk/repr-event (ptk/in-flight-events st/state)))))) + (catch :default _ + nil))) + (defn- download-report! [report event] (dom/prevent-default event) @@ -430,6 +490,7 @@ (defn flash "Show error notification banner and emit error report. A nil timeout keeps the notification visible until dismissed or replaced. + A `:tag` lets the notification be hidden later by that tag. The payload format is derived from the cause: environment failures get a compact report. The audit event name is the canonical one requested by @@ -460,7 +521,7 @@ synchronously from inside an error handler creates a re-entrant event-processing cycle that can exhaust the JS call stack (RangeError: Maximum call stack size exceeded)." - [& {:keys [type hint cause timeout report-link?] + [& {:keys [type hint cause timeout tag report-link? links] :or {type :handled timeout 5000}}] (js/Promise. (fn [resolve _reject] @@ -473,16 +534,26 @@ link-report (when report-link? (or report (when (ex/exception? cause) - (generate-report cause {:format (report-format cause)}))))] + (generate-report cause {:format (report-format cause)})))) + ;; The download link comes first because `:report-link?` is + ;; what puts the report on the toast at all; caller links are + ;; the recovery actions and follow it. + all-links (cond-> [] + link-report + (conj {:label (tr "labels.download" "report.txt") + :callback (partial download-report! link-report)}) + (seq links) (into links))] (try (st/emit! (ntf/show (cond-> {:content (or ^boolean hint (tr "errors.generic")) :type :toast :level :error :timeout timeout} - link-report - (assoc :links [{:label (tr "labels.download" "report.txt") - :callback (partial download-report! link-report)}])))) + (some? tag) + (assoc :tag tag) + + (seq all-links) + (assoc :links all-links)))) (catch :default err (.error js/console "error on emitting toast" err))) (resolve report))))))) @@ -498,6 +569,15 @@ :type :handled :hint (tr "errors.connection-error"))) +(defn- handle-transport-error + "Reports a failure whose answer did not come from the backend. These affect + one request and pass on their own, so the user keeps the page and gets a + toast." + [error prefix] + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix prefix)) + (flash :cause (::instance error) :type :handled)) + (defmethod ptk/handle-error :network [error] ;; Transient network errors (e.g. lost connectivity, DNS failure) @@ -511,6 +591,18 @@ ;; that would report it as an unhandled application error. (handle-connectivity-error error "Offline Error")) +(defmethod ptk/handle-error :gateway-error + [error] + (handle-connectivity-error error "Gateway Error")) + +(defmethod ptk/handle-error :rate-limit + [error] + (handle-transport-error error "Rate Limit Error")) + +(defmethod ptk/handle-error :unexpected-response + [error] + (handle-transport-error error "Unexpected Response")) + (def ^:private delegated-persistence-types "Save failure causes routed to their own error handler: retaining the changes cannot resolve them." @@ -523,8 +615,49 @@ ;; The retained changes no longer apply to the restored version. (= :vern-conflict code))) +(def ^:private unhandled-persistence-types + "Save failure causes that are application defects, not expected + outcomes: the client asked for something the server refused, or the + server or our own code broke. They must reach the internal reports and + the alerts, so they are reported as unhandled. + Environment failures are deliberately absent: the user's connection is + not our bug, and they land here once the retry budget is spent. + `:assertion` is absent on purpose too, because its own handler already + reports it as handled." + #{:validation :server-error :internal}) + +(defn- persistence-report-type + "The audit event name a save failure gets. `persistence-failed` wraps the + original cause under `:type :persistence` and keeps the real type in + `:cause-type`, so `:cause-type` wins: reading `:type` alone would match the + wrapper and never the cause." + [{:keys [type cause-type]}] + (if (contains? unhandled-persistence-types (or cause-type type)) + :unhandled + :handled)) + +(defn- reload-page-link + "Recovery action for a save failure: reload the window. + + Persistence stops for the session once a save fails terminally, and + reloading is the only way to get a fresh one without the user having to + know they can also leave the file and come back. `location.reload` is the + pattern used elsewhere in the app for a full restart." + [] + {:label (tr "labels.reload-page") + :callback (fn [event] + (dom/prevent-default event) + (js/location.reload))}) + +(def persistence-failed-tag + "Tag of the save failure notification, hidden once a save lands." + :persistence-failed) + (defn flash-persistence - [cause] + "Notifies a save failure. With `:retrying` the failure is transient and + the queue keeps trying in the background, so the toast says the changes + are kept and offers no reload, which would lose them." + [cause & {:keys [retrying]}] (let [data (ex-data cause)] (cond (delegated-persistence-failure? data) @@ -541,11 +674,21 @@ (= :branch-archived (:code data)) (st/emit! (ntf/warn (tr "errors.save-refused-archived-branch"))) + retrying + (flash :cause cause + :type (persistence-report-type data) + :timeout nil + :tag persistence-failed-tag + :report-link? true + :hint (tr "errors.save-still-retrying")) + :else (flash :cause cause - :type :handled + :type (persistence-report-type data) :timeout nil + :tag persistence-failed-tag :report-link? true + :links [(reload-page-link)] :hint (tr "errors.save-failed"))))) (defmethod ptk/handle-error :persistence diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 3650969c57..110e7ba1aa 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -32,6 +32,7 @@ #{:network ; js/fetch network-level failure :bad-gateway ; 502 :service-unavailable ; 503 + :gateway-error ; 504 and the Cloudflare 52x family :offline}) ; status 0 (browser offline) (defn retryable-error? @@ -86,6 +87,28 @@ ;; -- Response handling ------------------------------------------------------- +(def ^:private gateway-statuses + "Statuses raised by an edge proxy when the request never reached the + backend or its answer never came back." + #{504 ; gateway timeout + 520 ; cloudflare: unknown origin error + 521 ; cloudflare: origin is down + 522 ; cloudflare: connection timed out + 523 ; cloudflare: origin unreachable + 524}) ; cloudflare: origin timed out + +(def ^:private max-body-excerpt + "Number of characters kept from a body that we could not interpret." + 500) + +(defn- body-excerpt + "Keeps the head of an uninterpretable body. Error pages from proxies run + into tens of kilobytes and the excerpt is enough to recognize them." + [body] + (if (and (string? body) (> (count body) max-body-excerpt)) + (str (subs body 0 max-body-excerpt) "...") + body)) + (defn handle-response [{:keys [status body headers uri] :as response}] (cond @@ -128,6 +151,35 @@ (and (>= status 400) (map? body)) (rx/throw (ex-info "http error" (assoc body :uri uri :status status))) + ;; The branches below handle responses the backend never wrote: the + ;; body carries no error code, so the status is all we can read. + + (contains? gateway-statuses status) + (rx/throw + (ex/error :type :gateway-error + :code :gateway-error + :hint "the request did not reach the backend" + :uri uri + :status status)) + + (= 429 status) + (rx/throw + (ex/error :type :rate-limit + :code :rate-limited + :hint "too many requests" + :uri uri + :status status)) + + (>= status 400) + (rx/throw + (ex/error :type :unexpected-response + :code :unexpected-error-response + :hint "unexpected error response" + :uri uri + :status status + :headers headers + :data (body-excerpt body))) + :else (rx/throw (ex/error :type :internal @@ -136,7 +188,7 @@ :uri uri :status status :headers headers - :data body)))) + :data (body-excerpt body))))) (def default-options {:update-file {:query-params [:id]} 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); } } diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 76a7d1c37a..952714bec8 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/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/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 703a162e25..5574f21572 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -114,6 +114,7 @@ (rx/subs! (fn [result] (swap! fonts* df/merge-and-group-fonts installed-fonts result)) (fn [error] + (st/emit! (ntf/error (tr "errors.generic"))) (js/console.error "error" error)))))) on-upload 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/src/app/main/ui/dashboard/organization_team_switch.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs index 9d99ecb3c0..62144bfbf7 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs @@ -69,6 +69,18 @@ (nil? (dm/get-in team [:organization :id])) (contains? #{"unlimited" "enterprise"} (get-subscription-type (:subscription team))))) +(def ^:private combining-marks-re (js/RegExp. "\\p{M}" "gu")) + +(defn- name-sort-key + "Key that sorts `label` alphabetically regardless of case and + accents: NFD splits an accented letter into its base letter plus a + combining mark, and dropping the marks makes \"Ábaco\" sort like + \"abaco\" instead of after \"z\"." + [label] + (-> (.normalize label "NFD") + (str/replace combining-marks-re "") + (str/lower))) + (defn sort-organization-teams "Orders the teams of a single organization for the dropdown's second column: alphabetical, with the organization's default team @@ -76,7 +88,7 @@ `:is-default` and an explicit `false` into one comparator value." [teams] (sort-by (juxt (fn [team] (boolean (:is-default team))) - (fn [team] (str/lower (:name team "")))) + (fn [team] (name-sort-key (:name team "")))) teams)) (defn sort-all-teams @@ -89,7 +101,7 @@ (sort-all-teams teams team-display-name)) ([teams display-name] (sort-by (juxt (fn [team] (boolean (:is-default team))) - (comp str/lower display-name)) + (comp name-sort-key display-name)) teams))) (defn teams-for-organization @@ -113,7 +125,7 @@ stable." [organizations] (sort-by (juxt (fn [organization] (if (nil? (:id organization)) 1 0)) - (fn [organization] (str/lower (:name organization ""))) + (fn [organization] (name-sort-key (:name organization ""))) :id) organizations)) 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 c2aef5e875..3fee83b7a4 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -208,7 +208,12 @@ gap: var(--sp-s); top: calc(var(--sp-s) + #{$sz-64} + var(--sp-xs)); left: var(--sp-s); - height: px2rem(416); + + // Floor matches the previous fixed height; grow for ~6 org rows + left + // footer; scroll + fade kick in above the cap. + block-size: fit-content; + min-block-size: px2rem(416); + max-block-size: $sz-520; overflow: hidden; } 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/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); } 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); } 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; - } } 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; } 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/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 7dad14efd3..7e5bd6590d 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -177,6 +177,7 @@ (def ^:icon-id help "help") (def ^:icon-id hide "hide") (def ^:icon-id history "history") +(def ^:icon-id hourglass "hourglass") (def ^:icon-id hsva "hsva") (def ^:icon-id hug-content "hug-content") (def ^:icon-id icon "icon") diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index f1be8e32a2..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)} @@ -231,12 +223,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) @@ -290,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" @@ -313,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] @@ -341,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); } diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index a36545fb95..8a527c2209 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -20,6 +20,7 @@ [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [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.jobs.progress :as jp] [app.main.ui.notifications.context-notification :refer [context-notification]] @@ -134,7 +135,7 @@ {::mf/register modal/components ::mf/register-as ::fexp/export-files ::mf/props :obj} - [{:keys [team-id files]}] + [{:keys [team-id files unsaved]}] (let [state* (mf/use-state (partial initialize-state files)) has-libs? (some :has-libraries files) export-types (cond-> fexp/valid-types @@ -248,6 +249,13 @@ :on-click on-cancel :class (stl/css :modal-close-btn) :icon i/close}]] + + (when unsaved + [:> context-notification* {:level :warning + :appearance :ghost + :class (stl/css :unsaved-warning)} + (tr "files-download-modal.unsaved-changes")]) + (cond (= status :prepare) [:* diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index 62cc1deeab..b2a56e435e 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -39,6 +39,10 @@ max-block-size: calc(10 * px2rem(80)); } +.unsaved-warning { + margin-block-end: var(--sp-xxl); +} + .modal-content { display: flex; flex-direction: column; diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 3225932153..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] @@ -87,6 +88,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 +240,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 +266,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 +281,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 +291,7 @@ (fn [event] (dom/stop-propagation event) ;; (dnd/trace event data "drag-end") + (end-auto-scroll!) (rx/push! global-drag-end nil) (cleanup)) @@ -477,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/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 9e62720d02..7dec276e5d 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -150,6 +150,7 @@ (def ^:icon help (icon-xref :help)) (def ^:icon hide (icon-xref :hide)) (def ^:icon history (icon-xref :history)) +(def ^:icon hourglass (icon-xref :hourglass)) (def ^:icon hsva (icon-xref :hsva)) (def ^:icon hug-content (icon-xref :hug-content)) (def ^:icon icon (icon-xref :icon)) diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 78b748a064..d7a170a67e 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -14,20 +14,25 @@ [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] [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.code-gen.markup-svg-wasm :as svg.wasm] [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] @@ -113,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 @@ -148,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 @@ -278,8 +288,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 +302,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 +318,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 +343,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 +361,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/src/app/main/ui/inspect/exports.cljs b/frontend/src/app/main/ui/inspect/exports.cljs index dab7869948..c70e37ff05 100644 --- a/frontend/src/app/main/ui/inspect/exports.cljs +++ b/frontend/src/app/main/ui/inspect/exports.cljs @@ -14,7 +14,9 @@ [app.main.store :as st] [app.main.ui.components.select :refer [select]] [app.main.ui.components.title-bar :refer [title-bar*]] - [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.dom :as dom] [app.util.i18n :refer [tr c]] [app.util.keyboard :as kbd] @@ -161,17 +163,19 @@ [:> title-bar* {:collapsable false :title (tr "workspace.options.export") :class (stl/css :title-spacing-export-viewer)} - [:button {:class (stl/css :add-export) - :on-click add-export} deprecated-icon/add]]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.export.add-export") + :on-click add-export + :icon i/add}]]] (cond (= :multiple exports) [:div {:class (stl/css :multiple-exports)} [:div {:class (stl/css :label)} (tr "settings.multiple")] [:div {:class (stl/css :actions)} - [:button {:class (stl/css :action-btn) - :on-click ()} - deprecated-icon/remove-icon]]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.export.remove-export") + :icon i/remove}]]] (seq @exports) [:div {:class (stl/css :element-set-content)} @@ -183,14 +187,14 @@ [:& select {:default-value (d/name (:type export)) :options format-options - :dropdown-class (stl/css :dropdown-upwards) + :dropdown-class (stl/css :dropdown) :on-change (partial on-type-change index)}]] (when (scale-enabled? export) [:div {:class (stl/css :size-select)} [:& select {:default-value (str (:scale export)) :options size-options - :dropdown-class (stl/css :dropdown-upwards) + :dropdown-class (stl/css :dropdown) :on-change (partial on-scale-change index)}]]) [:label {:class (stl/css :suffix-input) :for "suffix-export-input"} @@ -203,16 +207,15 @@ :on-change on-suffix-change :on-key-down manage-key-down}]]] - [:button {:class (stl/css :action-btn) - :on-click (partial delete-export index)} - deprecated-icon/remove-icon]])]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.export.remove-export") + :on-click (partial delete-export index) + :icon i/remove}]])]) (when (or (= :multiple exports) (seq @exports)) - [:button - {:on-click (when-not in-progress? on-download) - :class (stl/css-case - :export-btn true - :btn-disabled in-progress?) - :disabled in-progress?} + [:> button* {:variant "secondary" + :class (stl/css :export-btn) + :on-click (when-not in-progress? on-download) + :disabled in-progress?} (if in-progress? (tr "workspace.options.exporting-object") (tr "workspace.options.export-object" (c (count shapes))))])])) diff --git a/frontend/src/app/main/ui/inspect/exports.scss b/frontend/src/app/main/ui/inspect/exports.scss index 1feb282b4c..67345837ac 100644 --- a/frontend/src/app/main/ui/inspect/exports.scss +++ b/frontend/src/app/main/ui/inspect/exports.scss @@ -5,9 +5,11 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_utils.scss" as *; .element-set { - padding-bottom: deprecated.$s-16; + padding-block-end: var(--sp-l); margin: 0; } @@ -17,33 +19,22 @@ .title-spacing-export-viewer { margin: 0; - color: var(--entry-foreground-color-hover); - margin-inline-start: calc(-1 * deprecated.$s-8); - width: calc(100% + deprecated.$s-8); -} - -.add-export { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } + color: var(--color-foreground-primary); + margin-inline-start: calc(-1 * var(--sp-s)); + inline-size: calc(100% + var(--sp-s)); } .element-set-content { - @include deprecated.flex-column; - - margin-bottom: deprecated.$s-4; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-end: var(--sp-xs); } .multiple-exports { - @include deprecated.flex-row; - + display: flex; + align-items: center; + gap: var(--sp-xs); grid-column: 1 / span 9; } @@ -52,24 +43,15 @@ } .actions { - @include deprecated.flex-row; + display: flex; + align-items: center; + gap: var(--sp-xs); } .element-group { display: grid; grid-template-columns: repeat(9, 1fr); - column-gap: deprecated.$s-4; - - .action-btn { - @extend %button-tertiary; - - height: deprecated.$s-32; - width: deprecated.$s-28; - - svg { - @extend %button-icon; - } - } + column-gap: var(--sp-xs); } .input-wrapper { @@ -82,10 +64,8 @@ grid-column: span 3; padding: 0; - .dropdown-upwards { - bottom: deprecated.$s-36; - width: deprecated.$s-80; - top: unset; + .dropdown { + inline-size: px2rem(80); } } @@ -93,24 +73,19 @@ grid-column: span 2; padding: 0; - .dropdown-upwards { - bottom: deprecated.$s-36; - top: unset; - width: deprecated.$s-80; + .dropdown { + inline-size: px2rem(80); } } .suffix-input { @extend %input-element; - @include deprecated.body-small-typography; + @include use-typography("body-small"); grid-column: span 3; } .export-btn { - @extend %button-secondary; - @include deprecated.uppercase-title-typography; - - height: deprecated.$s-32; - width: 100%; + justify-content: center; + inline-size: 100%; } 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; } 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; - } -} diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs index a7462d9380..43478a33f1 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs @@ -65,7 +65,7 @@ (mf/defc import-shortcuts-diff-modal {::mf/register modal/components ::mf/register-as :import-shortcuts-diff-modal} - [{:keys [imported-shortcuts custom-shortcuts all-shortcuts-raw]}] + [{:keys [imported-shortcuts skipped-entries custom-shortcuts all-shortcuts-raw]}] (let [diff-entries (mf/with-memo [imported-shortcuts custom-shortcuts] (->> (compute-diff imported-shortcuts custom-shortcuts) @@ -145,7 +145,17 @@ :is-customized true :has-conflict? false}])]]])))]] [:div {:class (stl/css :no-changes)} - (tr "import-shortcuts.no-changes")])] + (tr "import-shortcuts.no-changes")]) + + (when (seq skipped-entries) + [:div {:class (stl/css :skipped-entries)} + [:div {:class (stl/css :modal-content-text)} + (tr "import-shortcuts.skipped-entries")] + [:ul {:class (stl/css :skipped-entries-list)} + (for [entry skipped-entries] + [:li {:key entry + :class (stl/css :skipped-entry)} + entry])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss index e810578008..c28d5de804 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss @@ -165,3 +165,22 @@ text-align: center; padding-block: var(--sp-xl); } + +.skipped-entries { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.skipped-entries-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.skipped-entry { + @include t.use-typography("code-font"); + + color: var(--color-foreground-secondary); + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 7df63314d0..841841ac5b 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -23,6 +23,7 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.settings.import-shortcuts-diff-modal] [app.main.ui.settings.restore-shortcuts-modal] @@ -120,6 +121,53 @@ [:dashboard {:optional true} (context-schema :dashboard)] [:viewer {:optional true} (context-schema :viewer)]])) +(defn- entry-path + [& segments] + (str/join "/" (map d/name segments))) + +(defn- sanitize-context + "Keeps the entries of one imported context that map to a known shortcut key + with a valid command; every other entry goes to :skipped." + [result ctx entries known-keys] + (let [viable (reduce-kv + (fn [viable k command] + (if (and (contains? known-keys k) + (ss/valid-command? command) + (not (ss/reserved-command? command))) + (assoc viable k command) + viable)) + {} + entries) + skipped (->> (keys entries) + (remove #(contains? viable %)) + (map #(entry-path ctx %)))] + (cond-> (update result :skipped into skipped) + ;; A context left empty by skipped entries is dropped: an empty context + ;; clears the user's shortcuts on import, an explicit {} still does. + (or (seq viable) (empty? entries)) + (assoc-in [:shortcuts ctx] viable)))) + +(defn sanitize-imported-shortcuts + "Splits an imported custom-shortcuts payload into the entries Penpot can + apply and the ones it must skip: unknown contexts, unknown or + non-customizable shortcut keys, commands that are not a key combination + (see `app.main.ui.shortcuts/valid-command?`) and reserved combinations + (see `app.main.ui.shortcuts/reserved-command?`). + + Returns {:shortcuts :skipped [\"context/key\" ...]}, or + nil when the payload is not a map." + [data] + (when (map? data) + (-> (reduce-kv + (fn [result ctx entries] + (let [known-keys (get known-shortcut-keys ctx)] + (if (and (some? known-keys) (map? entries)) + (sanitize-context result ctx entries known-keys) + (update result :skipped conj (entry-path ctx))))) + {:shortcuts {} :skipped []} + data) + (update :skipped (comp vec sort))))) + (defn- flatten-humanize "Turns Malli's humanized explain output into a flat list of {:path :message} maps." ([human] (flatten-humanize [] human)) @@ -449,12 +497,16 @@ (rx/subs! (fn [content] (try - (let [shortcuts (js->clj (.parse js/JSON content) - :keywordize-keys true) - validation (validate-imported-shortcuts shortcuts)] - (if (:valid? validation) + (let [data (js->clj (.parse js/JSON content) + :keywordize-keys true) + {:keys [shortcuts skipped]} (sanitize-imported-shortcuts data)] + ;; Rejects the file only when nothing in it can be applied. + (if (and (some? shortcuts) + (or (seq shortcuts) (empty? skipped)) + (:valid? (validate-imported-shortcuts shortcuts))) (st/emit! (modal/show {:type :import-shortcuts-diff-modal :imported-shortcuts shortcuts + :skipped-entries skipped :custom-shortcuts custom-shortcuts :all-shortcuts-raw all-shortcuts-raw})) (st/emit! (ntf/error (tr "import-shortcuts.invalid-file"))))) @@ -477,6 +529,10 @@ [:> icon* {:icon-id i/info :class (stl/css :shortcuts-description-icon)}] (tr "shortcuts.reload-hint")] + (when-not (ds/layout-map-supported?) + [:> context-notification* {:level :warning + :class (stl/css :layout-map-warning)} + (tr "shortcuts.layout-map-missing")]) [:> (mf/provider ctx/shortcuts-ctx) {:value shortcuts-ctx-value} [:div {:class (stl/css :shortcuts-content)} diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss index 522147f61e..c86ca879b5 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -314,3 +314,7 @@ padding: var(--sp-s) 0; margin-block-end: var(--sp-l); } + +.layout-map-warning { + margin-block-end: var(--sp-m); +} diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index f502f5285f..d1db4cab5f 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -469,7 +469,14 @@ (= subscription-type "enterprise") (tr "subscription.settings.enterprise")) subscription-is-trial? - (= (:status subscription) "trialing") + (if nitrate? + (= (:status nitrate-license) "trialing") + (= (:status subscription) "trialing")) + + trial-end-date + (if nitrate? + (ct/format-inst (:trial-end nitrate-license) "d MMMM, yyyy") + (ct/format-inst (:trial-end subscription) "d MMMM, yyyy")) member-since (ct/format-inst (:created-at profile) "d MMMM, yyyy") @@ -701,12 +708,19 @@ [:div {:class (stl/css :membership-container)} (when (or nitrate? (and subscribed-since (not= subscription-type "professional"))) - [:div {:class (stl/css :membership)} - [:> icon* {:class (stl/css :subscription-member) - :icon-id "crown" - :size "m"}] - [:span {:class (stl/css :membership-date)} - (tr "subscription.settings.subscribed-since" subscription-name subscribed-since)]]) + (if subscription-is-trial? + [:div {:class (stl/css :membership)} + [:> icon* {:class (stl/css :subscription-member) + :icon-id "hourglass" + :size "m"}] + [:span {:class (stl/css :membership-date)} + (tr "subscription.settings.trial-ends-on" trial-end-date)]] + [:div {:class (stl/css :membership)} + [:> icon* {:class (stl/css :subscription-member) + :icon-id "crown" + :size "m"}] + [:span {:class (stl/css :membership-date)} + (tr "subscription.settings.subscribed-since" subscription-name subscribed-since)]])) [:div {:class (stl/css :membership)} [:> icon* {:class (stl/css :penpot-member) diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 74035a119f..30aed17a2c 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -34,40 +34,155 @@ (def ^:private modifier-keys #{"Control" "Shift" "Alt" "Meta"}) +(def ^:private punctuation-codes + #{"Minus" "Equal" "BracketLeft" "BracketRight" "Backslash" + "Semicolon" "Quote" "Backquote" "Comma" "Period" "Slash"}) + (def ^:private key-name-map {"ArrowUp" "up" + "Up" "up" "ArrowDown" "down" + "Down" "down" "ArrowLeft" "left" + "Left" "left" "ArrowRight" "right" + "Right" "right" "Escape" "escape" + "Esc" "escape" "Enter" "enter" "Backspace" "backspace" "Delete" "del" + "Del" "del" + "Insert" "ins" + "Ins" "ins" "Tab" "tab" + "Home" "home" + "End" "end" + "PageUp" "pageup" + "PageDown" "pagedown" + "CapsLock" "capslock" + "Spacebar" "space" + "Control" "ctrl" + "Shift" "shift" + "Alt" "alt" + "Meta" "meta" " " "space"}) -;; Mousetrap (the runtime shortcut matcher, vendored at -;; frontend/packages/mousetrap) resolves a keydown positionally - via keyCode -;; - ONLY for this punctuation set (its _KEYCODE_MAP); it falls back to -;; `event.key.toLowerCase()` (glyph-based) for letters and digits, which -;; aren't in that map. Recording punctuation from `code` therefore matches -;; what Mousetrap triggers regardless of layout (e.g. the key next to Right -;; Shift on a US layout is always "Period", whatever glyph it types on a -;; given ISO layout). With modifiers, letters and digits come from keyCode -;; like Mousetrap's keydown match (see `letter-or-digit`); otherwise from the -;; glyph, like its keypress match. -(def ^:private code-name-map - {"Minus" "-" - "Equal" "=" - "BracketLeft" "[" - "BracketRight" "]" - "Backslash" "\\" - "Semicolon" ";" - "Quote" "'" - "Backquote" "`" - "Comma" "," - "Period" "." - "Slash" "/"}) +;; Mirrors how mousetrap reads a bind string (`_keysFromString` and +;; `_getKeyInfo` in @penpot/mousetrap), so a command is valid exactly when +;; mousetrap can match it. + +(def ^:private command-aliases + {"ctrl" "ctrl" + "control" "ctrl" + "alt" "alt" + "option" "alt" + "shift" "shift" + "meta" "meta" + "command" "meta" + "return" "enter" + "enter" "enter" + "esc" "escape" + "escape" "escape" + "del" "del" + "delete" "del" + "ins" "ins" + "insert" "ins" + "space" "space" + "plus" "+"}) + +(def ^:private command-modifiers + #{"ctrl" "alt" "shift" "meta"}) + +(def ^:private command-named-keys + (into #{} (remove command-modifiers) (vals key-name-map))) + +(defn- canonical-command-key + [token] + (cond + ;; `mod` follows the platform, like `ds/c-mod`. + (= "mod" token) + (if (cf/check-platform? :macos) "meta" "ctrl") + + (contains? command-aliases token) + (get command-aliases token) + + (= 1 (count token)) + (str/lower token) + + :else + (str/lower (get key-name-map token token)))) + +(defn- command-step-keys + "Splits one step of a command (\"ctrl+shift+z\") into canonical keys. + A lone \"+\" is the plus key, and \"++\" ends a step with it." + [step] + (if (= "+" step) + ["+"] + (->> (.split (str/replace step #"\+{2}" "+plus") "+") + (mapv canonical-command-key)))) + +(defn- final-command-key? + [k] + (or (= 1 (count k)) + (contains? command-named-keys k) + ;; Two-digit alternatives first: cljs `re-matches` does not + ;; backtrack into a longer alternative. + (some? (re-matches #"f(?:1[0-9]|2[0-4]|[1-9])" k)) + (some? (re-matches #"num[0-9]" k)))) + +(defn- valid-command-step? + "A lone modifier is a valid step: `:show-measure` binds a bare `alt`." + [step-keys] + (let [final-key (peek step-keys)] + (if (contains? command-modifiers final-key) + (= 1 (count step-keys)) + (and (every? command-modifiers (pop step-keys)) + (final-command-key? final-key))))) + +(defn valid-command? + "True when `command` is a key combination mousetrap can match: steps + separated by single spaces, each a lone modifier or modifiers joined + with \"+\" ending with a single character, a named key (`escape`, `up`, + ...), a function key (`f1`..`f24`) or a numpad digit (`num0`..`num9`). + The empty string is valid: it disables the shortcut." + [command] + (and (string? command) + (or (= "" command) + (every? (comp valid-command-step? command-step-keys) + (.split command " "))))) + +(defn- canonical-command-step + "One step in the form mousetrap matches on: canonical keys with the + modifiers sorted, so `Shift+Ctrl+Z` and `ctrl+shift+z` compare equal." + [step] + (let [step-keys (command-step-keys step) + final-key (peek step-keys)] + (str/join "+" (conj (vec (sort (pop step-keys))) final-key)))) + +(defn- reserved-command-steps + "Key combinations of the non-customizable workspace shortcuts (cancel, + copy, paste): assigning them to another action would bind one key to two + actions. The path editor's cancel also binds `v`, the move tool key, + so path shortcuts are left out." + [] + (into #{} + (comp (filter #(false? (:customizable (val %)))) + (mapcat #(let [command (:command (val %))] + (if (vector? command) command [command]))) + (mapcat #(.split % " ")) + (map canonical-command-step)) + wsc/shortcuts)) + +(defn reserved-command? + "True when any step of `command` is a combination Penpot keeps for a + non-customizable shortcut." + [command] + (and (string? command) + (not= "" command) + (let [reserved (reserved-command-steps)] + (some? (some #(contains? reserved (canonical-command-step %)) + (.split command " ")))))) (defn- event->native "Unwraps a goog.events.BrowserEvent to the native event, since the @@ -77,42 +192,100 @@ (or (.getBrowserEvent event) event) event)) -(defn- letter-or-digit - "Letter or digit named by keyCode when a modifier is held, ignoring the glyph - it makes the key type (Option+Shift+5 types \"fi\" on macOS)." +(defn- alt-graph? [^js event] - (let [key-code (.-keyCode event)] - (when (and (or (.-ctrlKey event) (.-metaKey event) - (.-altKey event) (.-shiftKey event)) - (or (<= 48 key-code 57) - (<= 65 key-code 90))) - (.toLowerCase (js/String.fromCharCode key-code))))) + (boolean + (or (when (fn? (.-getModifierState event)) + (.getModifierState event "AltGraph")) + (let [native (event->native event)] + (when (and (not (identical? native event)) + (fn? (.-getModifierState native))) + (.getModifierState native "AltGraph")))))) + +(defn- numpad-code? + [code] + (and (string? code) + (str/starts-with? code "Numpad") + (= 1 (count (subs code 6))))) + +(defn- numpad-alias + [code] + (let [suffix (subs code 6)] + (when (re-matches #"[0-9]" suffix) + (str "num" suffix)))) + +(defn- punctuation-alias + "Resolves punctuation by physical position through the shared layout + map (`app.main.data.shortcuts/layout-map`, also pushed into Mousetrap), + so recording and dispatch use the same token. Without a map the typed + glyph applies, which keeps both sides consistent." + [code key] + (let [mapped (when (some? @ds/layout-map) + (.get ^js @ds/layout-map code))] + (if (string? mapped) + (str/lower mapped) + (str/lower key)))) + +(defn- digit-code? + [code] + (and (string? code) + (boolean (re-matches #"Digit[0-9]" code)))) + +(defn- digit-alias + "Shifted digits resolve to the digit itself: with Shift held, Digit0 + types ) on US layouts but 0 on AZERTY, so shift+0 must mean the + physical digit key everywhere, like shift+z means z." + [^js event code key] + (when (and (.-shiftKey ^js event) + (digit-code? code) + (= 1 (count key))) + (subs code 5))) (defn- normalize-key + "Normalizes `event.key` to the lowercase canonical token mousetrap + matches on. Letters and unshifted digits resolve by glyph (`a`, not + `q` on AZERTY); a digit row key held with Shift resolves to the digit + itself (`shift+0`, never `shift+)`); punctuation resolves by physical + position through the layout map, so `Shift+*` on a Spanish layout + records `shift++`, the primary key the user sees." [^js event] - (let [key (.-key event)] - (when (and key (not (contains? modifier-keys key))) - (or (get key-name-map key) - (get code-name-map (.-code (event->native event))) - (letter-or-digit event) - (some-> key .toLowerCase))))) + (let [native (event->native event) + key (.-key ^js event) + code (.-code ^js native)] + (when (and (string? key) (not (contains? modifier-keys key))) + (cond + ;; Numpad digits keep their own `numN` bindings, like mousetrap. + (numpad-code? code) + (numpad-alias code) + + ;; Punctuation: primary key of the physical position. + (contains? punctuation-codes code) + (punctuation-alias code key) + + :else + (or (get key-name-map key) + ;; Shifted digit row: the digit itself, on every layout. + (digit-alias event code key) + (str/lower key)))))) (defn- event->modifier-parts [^js event] - (cond-> [] - (and (.-ctrlKey event) - (not (cf/check-platform? :macos))) - (conj "ctrl") + (let [alt-graph? (alt-graph? event)] + (cond-> [] + (and (.-ctrlKey event) + (not (cf/check-platform? :macos)) + (not alt-graph?)) + (conj "ctrl") - (and (.-metaKey event) - (cf/check-platform? :macos)) - (conj "command") + (and (.-metaKey event) + (cf/check-platform? :macos)) + (conj "command") - (.-altKey event) - (conj "alt") + (.-altKey event) + (conj "alt") - (.-shiftKey event) - (conj "shift"))) + (.-shiftKey event) + (conj "shift")))) (defn- keyboard-event->mousetrap [^js event] @@ -544,12 +717,16 @@ effective-section-key)) (clean-editing-state))) + reserved? (and (some? recorded-command) + (not reset-pending) + (reserved-command? recorded-command)) + save-fn (mf/use-fn - (mf/deps recorded-command conflict command effective-section-key) + (mf/deps recorded-command reserved? conflict command effective-section-key) (fn [event] (dom/prevent-default event) - (when recorded-command + (when (and recorded-command (not reserved?)) (if (deref reset-pending*) (st/emit! (customize/reset-custom-shortcut command recorded-command effective-section-key)) (st/emit! (customize/set-custom-shortcut @@ -653,9 +830,16 @@ :has-conflict? false}]]) (when (and recorded-command (not reset-pending)) - (if conflict + (cond + reserved? + [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} + (tr "shortcuts.edit-modal.reserved")] + + conflict [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} (tr "shortcuts.edit-modal.conflict" (:name conflict))] + + :else [:> context-notification* {:level :success :class (stl/css :modal-success-msg)} (tr "shortcuts.edit-modal.success")])) [:div {:class (stl/css :edit-buttons)} @@ -668,6 +852,7 @@ [:> icon-button* {:variant "primary" :aria-label (tr "labels.save") :on-click save-fn + :disabled reserved? :icon i/tick :icon-size "m"}]] [:div {:class (stl/css :confirmation-buttons)} diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index ec5e20a72a..fe75e1f7b2 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -34,6 +34,7 @@ [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.v2.core :as rx] + [cuerdas.core :as str] [rumext.v2 :as mf])) ;; FIXME: this is a workaround until we export this class on beicon library @@ -608,16 +609,37 @@ [:> internal-error* props]))) +(defn screen-kind + "Classify the `screen` query param for the exception page. + + The param carries the route name (`?screen=¶ms`), so the + viewer is \"viewer\" and every team dashboard is \"dashboard-*\". Returns + `:workspace`, `:dashboard`, `:viewer`, or nil when the screen is not a + request-access candidate. Reads the param with `rt/get-query-param` + because a repeated param arrives as a vector." + [query-params] + (let [screen (rt/get-query-param query-params :screen)] + (cond + (= screen "workspace") + :workspace + + (and (string? screen) + (str/starts-with? screen "dashboard")) + :dashboard + + (= screen "viewer") + :viewer))) + (mf/defc exception-page* [{:keys [data route] :as props}] (let [type (:type data) params (:query-params route) - screen (get params :screen) - workspace? (= screen "workspace") - dashboard? (= screen "dashboard") - view? (= screen "view") + kind (screen-kind params) + workspace? (= kind :workspace) + dashboard? (= kind :dashboard) + view? (= kind :viewer) ;; We store the request access info int this state info* (mf/use-state nil) diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index 1009f2a6bf..7f64b0db1e 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/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); } 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, 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 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/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); } } } 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)} diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 6bf506c805..6cca735766 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -16,7 +16,7 @@ [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] - [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.tooltip :refer [tooltip*]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.branch-switcher :refer [branch-bar*]] @@ -157,14 +157,7 @@ :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)}] (when ^boolean review? [:> tooltip* {:content (tr "workspace.pull-requests.header-badge-tooltip" pr-title) :placement "bottom"} @@ -178,7 +171,8 @@ " " pr-title] display-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 2a72fd755a..82ce8d2c48 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -7,7 +7,7 @@ @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; .workspace-header-left-wrapper { display: flex; @@ -18,22 +18,24 @@ .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); } } @@ -41,49 +43,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 { @@ -111,46 +124,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); } } diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index ca7d530203..49f4758437 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -489,9 +489,10 @@ (tr "common.publish")])] (for [{:keys [id name data connected-to connected-to-names] :as library} linked-libraries] - (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) - has-tokens? (and (has-tokens? library) - (contains? cf/flags :token-import-from-library))] + (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) + can-import-tokens? (and (has-tokens? library) + (cfo/editable-tokens? local-library) + (contains? cf/flags :token-import-from-library))] [:div {:class (stl/css :section-list-item-legacy) :key (dm/str id) :data-testid "library-item"} @@ -508,7 +509,7 @@ [:span ")"]])])]] [:div {:class (stl/css :library-actions)} - (when ^boolean has-tokens? + (when ^boolean can-import-tokens? [:> icon-button* {:type "button" :aria-label (tr "workspace.tokens.import-tokens") @@ -865,9 +866,10 @@ :title-class (stl/css :connected-libraries-title)}] (if (seq linked-libraries) (for [{:keys [id name data connected-to nested? parent-name] :as library} linked-libraries] - (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) - has-tokens? (and (has-tokens? library) - (contains? cf/flags :token-import-from-library))] + (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) + can-import-tokens? (and (has-tokens? library) + (cfo/editable-tokens? local-library) + (contains? cf/flags :token-import-from-library))] [:div {:class (stl/css-case :section-list-item true :section-list-item-nested nested?) :key (dm/str id) @@ -893,7 +895,7 @@ :data-library-id (dm/str id) :on-click set-as-tokens-source} (tr "workspace.libraries.set-as-tokens-source")]) - (when ^boolean has-tokens? + (when ^boolean can-import-tokens? [:> icon-button* {:type "button" :aria-label (tr "workspace.tokens.import-tokens") diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index e9a4e2b837..db493b9459 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.workspace :as dw] [app.main.data.workspace.branches :as dwb] [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.undo :as dwu] @@ -605,9 +606,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/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; } 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 f8aa5967fa..29b1e7e106 100644 --- a/frontend/src/app/main/ui/workspace/palette.scss +++ b/frontend/src/app/main/ui/workspace/palette.scss @@ -4,22 +4,21 @@ // // 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; + inline-size: 100vw; + inset-inline-start: 0; // The branch banner hangs from this same bottom edge and publishes the room // it takes from it (`branches.cljs::publish-banner-clearance!`), so the bar // sits above the banner instead of over its controls. With no banner the // property is unset and the bar keeps its plain bottom anchor. - bottom: var(--branch-banner-clearance, 0); - padding-bottom: deprecated.$s-4; + inset-block-end: var(--branch-banner-clearance, 0); + padding-block-end: var(--sp-xs); /** Aligns AI Chat button **/ display: flex; @@ -28,20 +27,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, @@ -53,23 +58,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 { @@ -77,151 +80,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/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 12bd45e766..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 @@ -338,6 +343,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)} @@ -390,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)} @@ -402,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 @@ -454,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)))] @@ -465,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 @@ -509,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) @@ -523,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); } 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); } diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index 237ce41434..ace4f726a7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.geom.image-crop :as gic] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] @@ -20,6 +21,7 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.timers :as ts] + [cuerdas.core :as str] [rumext.v2 :as mf])) (defn get-shape-node @@ -31,6 +33,14 @@ base-node (dom/query base-node (dm/str "#shape-" id))))) +(defn- get-fill-image-nodes + "Retrieve the pictures of the shape image fills, inside its own fill + patterns" + [shape shape-node] + (when (or (some :fill-image (:fills shape)) + (some? (:fill-image shape))) + (dom/query-all shape-node ":scope > defs > pattern image"))) + (defn get-nodes "Retrieve the DOM nodes to apply the matrix transformation" [base-node {:keys [id parent-id] :as shape}] @@ -43,11 +53,13 @@ masking-child? (:masking-child? (meta shape))] (cond frame? - [shape-node - (dom/query shape-node ".frame-children") - (dom/query (dm/str "#thumbnail-container-" id)) - (dom/query (dm/str "#thumbnail-" id)) - (dom/query (dm/str "#frame-title-" id))] + (d/concat-vec + [shape-node + (dom/query shape-node ".frame-children") + (dom/query (dm/str "#thumbnail-container-" id)) + (dom/query (dm/str "#thumbnail-" id)) + (dom/query (dm/str "#frame-title-" id))] + (get-fill-image-nodes shape shape-node)) ;; For groups we don't want to transform the whole group but only ;; its filters/masks @@ -72,9 +84,38 @@ (dom/query (dm/str "#frame-title-" id)))] :else - [shape-node - (when (dbg/enabled? :shape-titles) - (dom/query (dm/str "#frame-title-" id)))])))) + (d/concat-vec + [shape-node + (when (dbg/enabled? :shape-titles) + (dom/query (dm/str "#frame-title-" id)))] + (get-fill-image-nodes shape shape-node)))))) + +(defn- fill-image-matrix + "Return the matrix that keeps the picture of an image fill `node` in + place while `modifiers` change the fill transform (resize with the mod + key). Return nil when the modifiers do not change the fill." + [shape modifiers node] + (let [changes (->> (:structure-parent modifiers) + (filter #(= :change-property (:type %))) + (d/index-by :property :value)) + selrect (dm/get-prop shape :selrect) + id (dom/get-attribute node "id") + + [transform transform' :as change] + (if (some? id) + ;; Fill images have an id ending with the fill index + (when-let [fills' (get changes :fills)] + (let [index (d/parse-integer (last (str/split id "-")))] + [(dm/get-in (nth (:fills shape) index nil) [:fill-image :transform]) + (dm/get-in (nth fills' index nil) [:fill-image :transform])])) + (when-let [fill-image' (get changes :fill-image)] + [(dm/get-in shape [:fill-image :transform]) + (get fill-image' :transform)]))] + + (when (some? change) + (gic/preview-image-matrix transform transform' + (dm/get-prop selrect :width) + (dm/get-prop selrect :height))))) (defn transform-region! [node modifiers] @@ -167,6 +208,13 @@ (= (dom/get-tag-name node) "pattern") (set-transform-att! node "patternTransform" transform) + ;; Picture of an image fill: it gets the shape transform + ;; through the shape node, and only needs its own when the + ;; fill transform changes + (= (dom/get-tag-name node) "image") + (when-let [matrix (fill-image-matrix shape modifiers node)] + (set-transform-att! node "transform" matrix)) + (and (some? transform) (some? node)) (set-transform-att! node "transform" transform))))))) 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 be9363656c..403f6c3030 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 @@ -624,6 +624,7 @@ (fn [] (on-blur) (st/emit! dw/hide-context-menu + (dwt/v3-stop-text-layout-preview shape-id) (dwu/commit-undo-transaction shape-id)) (text-editor/text-editor-dispose) (wasm.api/request-render-preserving-target "text-editor-dispose")))) 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 5a64f5341c..6d57b06db7 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 - var(--branch-bar-block-size, 0px)); + block-size: calc(100vh - #{px2rem(92)} - var(--branch-bar-block-size, 0px)); 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; - } -} 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; } 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/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); } 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/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); + } } 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); - } - } - } -} 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..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 @@ -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,35 +19,13 @@ } .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 { + --radio-btn-border-color: transparent; + --radio-btn-border-color-selected: transparent; + background-color: transparent; gap: var(--sp-xs); } 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; } 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..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 @@ -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"}] @@ -198,31 +201,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)} @@ -618,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)] @@ -896,10 +904,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 +922,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 +1051,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 +1081,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 +1166,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 +1299,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 +1319,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..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 @@ -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,16 +67,12 @@ .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); } - &.expandeable { - cursor: pointer; - } - &:hover { .annotation-title-icon-action { display: flex; @@ -76,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; } @@ -90,29 +110,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 { @@ -329,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); @@ -489,11 +495,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 +589,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 +625,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 { 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/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); } 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..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 @@ -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] @@ -192,13 +193,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 +662,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 @@ -847,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,9 +986,16 @@ :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} - (if is-column deprecated-icon/flex-vertical deprecated-icon/flex-horizontal)] + ;; 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)}]] [:div {:class (stl/css :track-info-value)} [:> deprecated-input/numeric-input* {:no-validate true @@ -1038,11 +1043,20 @@ [: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] + ;; 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] [: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* {} @@ -1570,8 +1584,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 7182dc5729..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 @@ -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; @@ -61,25 +63,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 +138,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; } @@ -214,22 +178,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; } @@ -275,22 +231,18 @@ grid-column: 1 / span 7; } -// TODO: Replace this buttons with DS buttons .track-info-dir-icon { + 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); + 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); } } @@ -347,7 +299,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; @@ -355,38 +308,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); } } 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 10b9c42db7..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 @@ -602,7 +604,6 @@ :input-type :min-width :nillable true :applied-token applied-token-to-min-w - :tooltip-class (stl/css :tooltip-wrapper) :value min-w}] [:div {:class (stl/css :layout-item-min-w) @@ -632,7 +633,6 @@ :attr :layout-item-max-w :property (tr "workspace.options.layout-item.layout-item-max-w") :nillable true - :tooltip-class (stl/css :tooltip-wrapper) :applied-token applied-token-to-max-w :value max-w}] @@ -663,7 +663,6 @@ :attr :layout-item-min-h :property (tr "workspace.options.layout-item.layout-item-min-h") :nillable true - :tooltip-class (stl/css :tooltip-wrapper) :applied-token applied-token-to-min-h :value min-h}] @@ -694,7 +693,6 @@ :attr :layout-item-max-h :property (tr "workspace.options.layout-item.layout-item-max-h") :nillable true - :tooltip-class (stl/css :tooltip-wrapper) :applied-token applied-token-to-max-h :value max-h}] 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 b362df51d0..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 { @@ -192,7 +181,3 @@ .inputs-wrapper { grid-column: 1 / span 2; } - -.tooltip-wrapper { - inline-size: 100%; -} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index b398f099fc..3f56fbbc23 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -154,6 +154,8 @@ (get n-values :rotation)) (identical? (get o-values :hide-in-viewer) (get n-values :hide-in-viewer)) + (identical? (get o-values :show-content) + (get n-values :show-content)) (identical? (get o-values :width) (get n-values :width)) (identical? (get o-values :height) 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/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index f4974abc7b..2a6b472977 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -291,21 +291,14 @@ (mf/defc font-item* {::mf/wrap [mf/memo]} - [{:keys [font is-current on-click style]}] - (let [item-ref (mf/use-ref) - on-click (mf/use-fn (mf/deps font) #(on-click font))] - - (mf/with-effect [is-current] - (when is-current - (let [element (mf/ref-val item-ref)] - (when-not (dom/is-in-viewport? element) - (dom/scroll-into-view! element))))) - + [{:keys [font is-current is-focused on-click style]}] + (let [on-click (mf/use-fn (mf/deps font) #(on-click font))] [:div {:class (stl/css :font-wrapper) :style style - :ref item-ref :on-click on-click} - [:div {:class (stl/css-case :font-item true :selected is-current)} + [:div {:class (stl/css-case :font-item true + :selected is-current + :focused is-focused)} [:> font-item-preview* {:font font}] (when is-current [:> icon* {:icon-id i/tick @@ -326,11 +319,20 @@ (mf/defc font-selector* [{:keys [on-select on-close current-font show-recent full-size]}] - (let [selected (mf/use-state current-font) + (let [;; The tick marks the font the text had when the selector opened. + ;; The focus (green border) marks the font Enter applies: arrows move + ;; it and preview the font, Esc and an outside click undo the preview + initial-font (mf/with-memo [] current-font) + focused* (mf/use-state current-font) + focused (deref focused*) + previewed? (mf/use-ref false) + preview? (mf/use-ref false) + state* (mf/use-state #(do {:term "" :backends #{}})) state (deref state*) + root (mf/use-ref) flist (mf/use-ref) input (mf/use-ref) @@ -353,52 +355,57 @@ (filter #(contains? installed-ids (:id %))) (filter-fonts state))) - ;; When the active font is not in the filtered results, pre-select - ;; the first match visually so the user can confirm it with Enter — - ;; without live-applying it on every keystroke. - effective-selected - (if (and (seq fonts) (not (d/seek #(= (:id %) (:id @selected)) fonts))) - (first fonts) - @selected) - full-size? (boolean (and full-size show-recent)) - select-next - (mf/use-fn - (mf/deps fonts) - (fn [event] - (dom/stop-propagation event) - (dom/prevent-default event) - (swap! selected get-next-font fonts))) + ;; `recent-fonts` is a vector, so check it with `seq`: an empty one + ;; is truthy and would show the heading over nothing + show-recent? (boolean (and show-recent (seq recent-fonts))) - select-prev + ;; Arrows only move the focus, from its latest value: a held key + ;; fires again before the listener sees the new state. The preview + ;; follows in an effect + focus-and-preview (mf/use-fn (mf/deps fonts) - (fn [event] + (fn [event get-font] (dom/stop-propagation event) (dom/prevent-default event) - (swap! selected get-prev-font fonts))) + (when (seq fonts) + (mf/set-ref-val! preview? true) + (swap! focused* get-font fonts)))) + + on-cancel + (mf/use-fn + (mf/deps initial-font on-select on-close) + (fn [] + (when (mf/ref-val previewed?) + (on-select initial-font)) + (on-close))) on-key-down (mf/use-fn - (mf/deps fonts on-select on-close) + (mf/deps focused focus-and-preview on-cancel on-select on-close) (fn [event] (cond - (kbd/up-arrow? event) (select-prev event) - (kbd/down-arrow? event) (select-next event) - (kbd/esc? event) (on-close) - (kbd/enter? event) - (let [first-result (when-not (d/seek #(= (:id %) (:id @selected)) fonts) - (first fonts))] - (if first-result - (do (on-select first-result) (on-close)) - (on-close))) + (kbd/up-arrow? event) (focus-and-preview event get-prev-font) + (kbd/down-arrow? event) (focus-and-preview event get-next-font) + (kbd/esc? event) (on-cancel) + (kbd/enter? event) (do (when (some? focused) + (on-select focused)) + (on-close)) :else (dom/focus! (mf/ref-val input))))) on-filter-change (mf/use-fn - (fn [event] - (swap! state* assoc :term event))) + (mf/deps state all-fonts) + (fn [term] + ;; Move the focus to the first result, without previewing it. + ;; `swap!` on a `use-state` returns nil, so build the new state here + (let [state (assoc state :term term)] + (reset! state* state) + (mf/set-ref-val! preview? false) + (when (seq term) + (reset! focused* (first (filter-fonts state all-fonts))))))) on-select-and-close (mf/use-fn @@ -407,10 +414,29 @@ (on-select font) (on-close)))] - (mf/with-effect [fonts on-key-down] + (mf/with-effect [on-key-down] (let [key (events/listen js/document "keydown" on-key-down)] #(events/unlistenByKey key))) + ;; Close on an outside click. Starts on the next tick to skip the + ;; opening click; detached targets (a just-selected row) are ignored + (mf/with-effect [on-cancel] + (let [listening? (volatile! false) + on-click (fn [event] + (let [target (dom/get-target event) + node (mf/ref-val root)] + (when (and @listening? + (some? node) + (.-isConnected ^js target) + (not (dom/child? target node))) + (on-cancel)))) + task (tm/schedule #(vreset! listening? true)) + keys [(events/listen js/document "click" on-click) + (events/listen js/document "contextmenu" on-click)]] + (fn [] + (tm/dispose! task) + (run! events/unlistenByKey keys)))) + ;; Materialize the preview sprite into the DOM only while the picker is open ;; (markup is prefetched on workspace load), removing it on close so its ;; ~2000 nodes aren't kept around idle. The attachment is deferred so the @@ -427,16 +453,26 @@ (when-some [n @node*] (fonts/detach-preview-sprite! n)))))) - (mf/with-effect [@selected] + ;; Preview the focused font once the arrows stop for a moment. Applying + ;; every font of a held key floods the store and the text editor with + ;; updates, until React stops with "Maximum update depth exceeded" + (mf/with-effect [focused] + (when (mf/ref-val preview?) + (mf/set-ref-val! preview? false) + (let [task (tm/schedule 100 (fn [] + (mf/set-ref-val! previewed? true) + (on-select focused)))] + #(tm/dispose! task)))) + + ;; The only scroll to the focused row: the list is virtualized, so the row + ;; may not be rendered yet + (mf/with-effect [focused fonts] (let [node (mf/ref-val flist) - index (:index @selected)] + index (d/index-of-pred fonts #(= (:id %) (:id focused)))] ;; This is nil safe operation, do nothing if node or index are ;; invalid. (dom/scroll-to-row node index))) - (mf/with-effect [@selected] - (on-select @selected)) - (mf/with-effect [] (st/emit! (dsc/push-shortcuts :typography {} :workspace)) (fn [] @@ -451,7 +487,8 @@ (dom/scroll-to-position node offset))))) [:div {:class [(stl/css-case :font-selector true - :fonts-on-modal (not full-size?))]} + :fonts-on-modal (not full-size?))] + :ref root} [:div {:class (stl/css-case :font-selector-dropdown true :font-selector-dropdown-full-size full-size?)} [:div {:class (stl/css :header)} @@ -459,7 +496,7 @@ :value (:term state) :auto-focus true :placeholder (tr "workspace.options.search-font")}] - (when (and recent-fonts show-recent) + (when show-recent? [:section {:class (stl/css :show-recent)} [:p {:class (stl/css :header-title)} (tr "workspace.options.recent-fonts")] (for [[idx font] (d/enumerate recent-fonts)] @@ -467,15 +504,21 @@ :font font :style {} :on-click on-select-and-close - :is-current (= (:id font) (:id effective-selected))}])])] + ;; Recent fonts are reached with the mouse or by + ;; filtering, never with the arrow keys, so no focus + :is-current (= (:id font) (:id initial-font))}])])] [:div {:class (stl/css-case :fonts-list true - :fonts-list-full-size full-size?)} + :fonts-list-full-size full-size? + :fonts-list-below-recent show-recent?)} + (when (empty? fonts) + [:p {:class (stl/css :fonts-list-empty)} + (tr "workspace.options.no-font-results")]) [:> rvt/AutoSizer {} (fn [props] (let [width (unchecked-get props "width") height (unchecked-get props "height") - render #(row-renderer fonts effective-selected on-select-and-close %)] + render #(row-renderer fonts (:id initial-font) (:id focused) on-select-and-close %)] (mf/html [:> rvt/List #js {:height height :ref flist @@ -485,7 +528,7 @@ :rowRenderer render}])))]]]])) (defn row-renderer - [fonts selected on-select props] + [fonts current-id focused-id on-select props] (let [index (unchecked-get props "index") key (unchecked-get props "key") style (unchecked-get props "style") @@ -495,7 +538,8 @@ :font font :style style :on-click on-select - :is-current (= (:id font) (:id selected))}]))) + :is-current (= (:id font) current-id) + :is-focused (= (:id font) focused-id)}]))) (mf/defc font-options* [{:keys [values on-change on-blur show-recent full-size-selector]}] 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 606303bf47..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 @@ -13,6 +13,8 @@ // Must match BOX_HEIGHT in scripts/build-fonts-preview.js (the sprite bakes each // font name into that box at 1 unit == 1px). +// NOTE: kept in px on purpose, not px2rem(): the sprite is baked in pixels, so +// the box must not scale with the root font size. $font-preview-box-height: 28px; .typography-entry { @@ -116,20 +118,25 @@ $font-preview-box-height: 28px; } .typography-sample-input { + // No DS typography on purpose: the "Ag" sample is drawn in the + // typography's own font + font-size: px2rem(16); + line-height: 1; display: flex; justify-content: center; align-items: center; 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); } .adv-typography-name { + // TODO: No exact DS typography match found. Rename input of a + // typography; only the font size is set. + // Closest match: body-small (12px). font-size: px2rem(12); color: var(--font-name-wrapper-foreground-color); flex-grow: 1; @@ -161,26 +168,31 @@ $font-preview-box-height: 28px; } .typography-name-wrapper { - @extend %asset-element; + @include t.use-typography("body-small"); display: grid; grid-template-columns: minmax($sz-24, auto) auto 1fr $sz-28; column-gap: var(--sp-xs); flex: 1; + align-items: center; block-size: $sz-32; inline-size: 100%; padding: 0 0 0 var(--sp-m); + border-radius: $br-8; background-color: var(--color-background-quaternary); + color: var(--color-foreground-primary); margin-block-end: var(--sp-xs); } .typography-sample { + // No DS typography on purpose: the "Ag" sample is drawn in the + // typography's own font + font-size: px2rem(16); + line-height: 1; display: flex; justify-content: center; align-items: center; min-inline-size: $sz-24; - font-size: px2rem(16); - line-height: 1; block-size: $sz-32; white-space: nowrap; padding: 0; @@ -194,8 +206,8 @@ $font-preview-box-height: 28px; margin: 0; min-inline-size: px2rem(6); - padding: 8px 0 8px 6px; - max-width: 100%; + padding: var(--sp-s) 0 var(--sp-s) px2rem(6); + max-inline-size: 100%; color: var(--color-foreground-primary); } @@ -218,7 +230,7 @@ $font-preview-box-height: 28px; display: grid; grid-template-columns: 50% 50%; block-size: $sz-32; - padding-left: var(--sp-xs); + padding-inline-start: var(--sp-xs); } .info-label, @@ -243,11 +255,22 @@ $font-preview-box-height: 28px; } .font-option { - @include t.use-typography("body-small"); - @extend %asset-element; + --font-option-background-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + block-size: $sz-32; padding: var(--sp-s); + border-radius: $br-8; + background-color: var(--font-option-background-color); + color: var(--color-foreground-primary); cursor: pointer; + + &:hover { + --font-option-background-color: var(--color-background-quaternary); + } } .font-option-name { @@ -263,15 +286,26 @@ $font-preview-box-height: 28px; } .font-size-options { - @extend %asset-element; + --font-size-options-background-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); + display: flex; flex-grow: 1; + align-items: center; + block-size: $sz-32; inline-size: px2rem(60); margin: 0; padding: 0; border: $b-1 solid var(--color-background-tertiary); + border-radius: $br-8; + background-color: var(--font-size-options-background-color); + color: var(--color-foreground-primary); position: relative; + + &:hover { + --font-size-options-background-color: var(--color-background-quaternary); + } } .font-variant-options { @@ -291,6 +325,9 @@ $font-preview-box-height: 28px; gap: var(--sp-xs); } +// TODO: Kept on the deprecated %input-element and %button-icon-small +// placeholders, which style the icon svg and the input elements. They go +// away when these controls move to the DS numeric-input*. .line-height, .letter-spacing { @extend %input-element; @@ -309,11 +346,22 @@ $font-preview-box-height: 28px; } .text-transform { - @extend %asset-element; + --text-transform-background-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + block-size: $sz-32; inline-size: fit-content; padding: 0; - background-color: var(--color-background-tertiary); + border-radius: $br-8; + background-color: var(--text-transform-background-color); + color: var(--color-foreground-primary); + + &:hover { + --text-transform-background-color: var(--color-background-quaternary); + } } .font-size-select { @@ -328,10 +376,24 @@ $font-preview-box-height: 28px; outline: none; .numeric-input { - @extend %input-base; @include t.use-typography("body-small"); + @include text-ellipsis; + block-size: $sz-28; + inline-size: 100%; + flex-grow: 1; + margin: var(--sp-xxs) 0; padding: 0; + border: none; + border-radius: $br-8; + background: none; + outline: none; + color: var(--color-foreground-primary); + + &[disabled] { + opacity: 0.5; + pointer-events: none; + } } } @@ -340,16 +402,15 @@ $font-preview-box-height: 28px; justify-content: center; align-items: center; position: absolute; - top: calc(-1 * #{px2rem(18)}); - left: 0; - right: 0; + inset-block-start: calc(-1 * #{px2rem(18)}); + inset-inline: 0; block-size: 100%; inline-size: 100%; z-index: var(--z-index-dropdown); } .fonts-on-modal { - top: 0; + inset-block-start: 0; } .show-recent { @@ -385,22 +446,39 @@ $font-preview-box-height: 28px; } .font-wrapper { - padding-bottom: var(--sp-xs); + padding-block-end: var(--sp-xs); cursor: pointer; } .font-item { - @extend %asset-element; + --font-item-background-color: var(--color-background-tertiary); + + @include t.use-typography("body-small"); - width: px2rem(280); - margin-bottom: var(--sp-xs); - border-radius: $br-8; display: flex; + block-size: $sz-32; + inline-size: 100%; + margin-block-end: var(--sp-xs); + padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; + background-color: var(--font-item-background-color); + color: var(--color-foreground-primary); + + &:hover { + --font-item-background-color: var(--color-background-quaternary); + } &.selected { color: var(--color-foreground-primary); } + // The font Enter applies, same border as the focused option of the + // select dropdowns + &.focused { + outline: $b-1 solid var(--color-accent-primary); + outline-offset: calc(-1 * #{$b-1}); + } + // Center & clip so a previewed font's own metrics never grow/overflow the row. align-items: center; overflow: hidden; @@ -456,9 +534,21 @@ $font-preview-box-height: 28px; } .fonts-list-full-size { + border: $b-1 solid var(--color-background-quaternary); +} + +// Joins the list to the "Recent" section above it +.fonts-list-below-recent { border-start-start-radius: 0; border-start-end-radius: 0; - border: $b-1 solid var(--color-background-quaternary); +} + +.fonts-list-empty { + @include t.use-typography("body-small"); + + color: var(--color-foreground-secondary); + margin: 0; + padding: var(--sp-m); } .dropdown-icon { 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; } } 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}] 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); } } } diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index c50c8e0d58..dbf6e42980 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -26,6 +26,13 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) +(defn token-in-path? + "True when `token-name` is exactly `path` or nested below it. This keeps an + ancestor token inside the operations of its own path-based group." + [path token-name] + (or (= token-name path) + (str/starts-with? token-name (str path ctob/token-separator)))) + (defn- get-sorted-token-groups "Separate token-types into groups of `empty` or `filled` depending if tokens exist for that type. Sort each group alphabetically (by their type)." @@ -130,9 +137,9 @@ (fn [tokens-filtered-by-type node] (->> tokens-filtered-by-type (filter (fn [token] - (let [token-path (cpn/split-path (:name token) :separator ".")] + (let [token-path (cpn/split-path (:name token) :separator ctob/token-separator)] (and (> (count token-path) 0) - (str/starts-with? (:name token) (str (:path node) "."))))))))) + (token-in-path? (:path node) (:name token))))))))) ;; Filter tokens by their path and return their ids filter-tokens-by-path-ids @@ -142,7 +149,8 @@ (->> selected-token-set-tokens (filter (fn [token] (let [[_ token-value] token] - (and (= (:type token-value) type) (str/starts-with? (:name token-value) (str path ".")))))) + (and (= (:type token-value) type) + (token-in-path? path (:name token-value)))))) (mapv (fn [token] (let [[_ token-value] token] (:id token-value))))))) 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 88e8346580..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/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/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 4fb9be3e4f..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,9 +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 ts/tokenscript-symbols->penpot-unit))] + (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/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index 3a5973209a..55bbffd676 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -31,8 +31,56 @@ [:on-node-context-menu {:optional true} fn?] [:can-edit {:optional true} :boolean]]) +(defn- leaf-node? + "A tree node is a leaf when it holds a token and has no children." + [node] + (and (:leaf node) (not (seq (:children node))))) + +(defn- sort-nodes + "Sorts tree nodes so that leaf tokens come before groups, sorting each + set naturally by name." + [nodes] + (let [[leafs groups] + (reduce (fn [[leafs groups] node] + (if (leaf-node? node) + [(conj leafs node) groups] + [leafs (conj groups node)])) + [[] []] + nodes)] + (concat (d/natural-sort-by :name leafs) + (d/natural-sort-by :name groups)))) + +(def ^:private schema:leaf-node + [:map + [:leaf :any] + [:tokens-lib {:optional true} :any] + [:selected-token-set-id {:optional true} :any] + [:selected-shapes :any] + [:is-selected-inside-layout {:optional true} :boolean] + [:active-theme-tokens {:optional true} :any] + [:can-edit {:optional true} :boolean] + [:on-token-pill-click {:optional true} fn?] + [:on-pill-context-menu {:optional true} fn?]]) + +(mf/defc leaf-node* + {::mf/private true + ::mf/schema schema:leaf-node} + [{:keys [leaf tokens-lib selected-token-set-id selected-shapes + is-selected-inside-layout active-theme-tokens can-edit + on-token-pill-click on-pill-context-menu]}] + (let [id (:id leaf) + token (ctob/get-token tokens-lib selected-token-set-id id)] + [:> token-pill* {:token token + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-click on-token-pill-click + :can-edit can-edit + :on-context-menu on-pill-context-menu}])) + (mf/defc folder-node* - {::mf/schema schema:folder-node} + {::mf/private true + ::mf/schema schema:folder-node} [{:keys [node type folded-token-paths @@ -47,20 +95,12 @@ can-edit]}] (let [full-path (str (name type) "." (:path node)) is-folder-expanded (not (contains? (set (or folded-token-paths [])) full-path)) + children (:children node) sorted-children (mf/with-memo [children] - (let [[leafs groups] - (reduce (fn [[l g] item] - (if (:leaf item) - [(conj l item) g] - [l (conj g item)])) - [[] []] - children) - sorted-leafs (d/natural-sort-by :name leafs) - sorted-groups (d/natural-sort-by :name groups)] - (concat sorted-leafs sorted-groups))) + (sort-nodes children)) swap-folder-expanded (mf/use-fn @@ -80,40 +120,50 @@ :expanded is-folder-expanded :aria-expanded is-folder-expanded :aria-controls (str "folder-children-" (:path node)) - :is-expandable (not (:leaf node)) + :is-expandable (boolean (seq children)) :on-toggle-expand swap-folder-expanded :on-context-menu node-context-menu-prep}] (when is-folder-expanded [:div {:class (stl/css :folder-children-wrapper) :id (str "folder-children-" (:path node))} - (when (seq children) - (for [child sorted-children] - (if (not (:leaf child)) - [:ul {:class (stl/css :node-parent) - :key (:path child)} - [:> folder-node* {:type type - :node child - :folded-token-paths folded-token-paths - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-token-pill-click on-token-pill-click - :on-pill-context-menu on-pill-context-menu - :on-node-context-menu on-node-context-menu - :tokens-lib tokens-lib - :can-edit can-edit - :selected-token-set-id selected-token-set-id}]] - (let [id (:id (:leaf child)) - token (ctob/get-token tokens-lib selected-token-set-id id)] - [:> token-pill* - {:key id - :token token - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-click on-token-pill-click - :can-edit can-edit - :on-context-menu on-pill-context-menu}]))))])])) + (when-let [leaf (:leaf node)] + [:> leaf-node* {:key (:id leaf) + :leaf leaf + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :can-edit can-edit + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu}]) + + (for [child sorted-children] + (if (leaf-node? child) + [:> leaf-node* {:key (:id (:leaf child)) + :leaf (:leaf child) + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :can-edit can-edit + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu}] + [:ul {:class (stl/css :node-parent) + :key (:path child)} + [:> folder-node* {:type type + :node child + :folded-token-paths folded-token-paths + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu + :on-node-context-menu on-node-context-menu + :tokens-lib tokens-lib + :can-edit can-edit + :selected-token-set-id selected-token-set-id}]]))])])) (def ^:private schema:token-tree [:map @@ -146,42 +196,35 @@ on-node-context-menu can-edit can-edit-tokens]}] - (let [separator "." - raw-tree + (let [raw-tree (mf/with-memo [tokens] - (cpn/build-tree-root tokens separator)) + (cpn/build-tree-root tokens ctob/token-separator)) - on-node-context-menu (mf/use-fn - (mf/deps can-edit-tokens on-node-context-menu) - (fn [event node] - (when can-edit-tokens - (on-node-context-menu event node)))) + ordered-nodes + (mf/with-memo [raw-tree] + (sort-nodes raw-tree)) + + on-node-context-menu + (mf/use-fn + (mf/deps can-edit-tokens on-node-context-menu) + (fn [event node] + (when can-edit-tokens + (on-node-context-menu event node))))] - ordered-nodes (mf/with-memo [raw-tree] - (let [[leafs groups] - (reduce (fn [[l g] item] - (if (:leaf item) - [(conj l item) g] - [l (conj g item)])) - [[] []] - raw-tree) - sorted-leafs (d/natural-sort-by :name leafs) - sorted-groups (d/natural-sort-by :name groups)] - (concat sorted-leafs sorted-groups)))] [:div {:class (stl/css :token-tree-wrapper)} (for [node ordered-nodes] - (if (:leaf node) - (let [token (ctob/get-token tokens-lib selected-token-set-id (get-in node [:leaf :id]))] - [:> token-pill* - {:token token - :key (:id (:leaf node)) - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-click on-token-pill-click - :can-edit can-edit - :on-context-menu on-pill-context-menu}]) - ;; Render segment folder + (if (leaf-node? node) + [:> leaf-node* {:key (:id (:leaf node)) + :leaf (:leaf node) + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :can-edit can-edit + :on-token-pill-click on-token-pill-click + :on-pill-context-menu on-pill-context-menu}] + [:ul {:class (stl/css :node-parent) :key (:path node)} [:> folder-node* {:node node diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss index 24e7c22f87..023fc72182 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss @@ -28,7 +28,7 @@ overflow-y: auto; scrollbar-gutter: stable; position: relative; - padding-block: var(--sp-xs) var(--sp-l); + padding-block-start: var(--sp-xs); } .tokens-section-wrapper { 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/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 6b91ba1984..ae8397383e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -402,7 +402,7 @@ (defn inside-vbox [vbox objects frame-id] (let [frame (get objects frame-id)] - (and (some? frame) (gsh/overlaps? frame vbox)))) + (and (some? frame) (some? vbox) (gsh/overlaps? frame vbox)))) (defn setup-active-frames [objects hover-ids selected active-frames zoom transform vbox] 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 { diff --git a/frontend/src/app/main/worker.cljs b/frontend/src/app/main/worker.cljs index bbfec651c6..4daaeebc36 100644 --- a/frontend/src/app/main/worker.cljs +++ b/frontend/src/app/main/worker.cljs @@ -22,7 +22,8 @@ (uw/ask! worker {:cmd :configure :config {:public-uri cf/public-uri :build-data cf/build-date - :version cf/version}}) + :version cf/version + :version-tag cf/version-tag}}) (set! instance worker))) (defn ask! 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..ed3c12c906 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.tokens :as cfo] [app.common.files.variant :as cfv] [app.common.geom.point :as gpt] [app.common.schema :as sm] @@ -19,8 +20,10 @@ [app.common.types.variant :as ctv] [app.common.uuid :as uuid] [app.main.data.plugins :as dp] + [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.variants :as dwv] [app.main.fonts :as fonts] [app.main.repo :as rp] @@ -845,12 +848,81 @@ :remove (fn [] - (cond - (not (r/check-permission plugin-id "library:write")) - (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") - :else - (st/emit! (dwl/delete-component {:id id})))) + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :remove "Can only remove components from the current file's library") + + (nil? component) + (u/not-valid plugin-id :remove "Component does not exist") + + (not (:deleted component)) + (st/emit! (-> (dwl/delete-component {:id id}) + (se/add-event plugin-id)))))) + + :annotation + {:get (fn [] (:annotation (u/locate-library-component file-id id))) + :set + (fn [value] + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :annotation "Plugin doesn't have 'library:write' permission") + + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :annotation "Can only edit annotations in the current file's library") + + (nil? component) + (u/not-valid plugin-id :annotation "Component does not exist") + + (:deleted component) + (u/not-valid plugin-id :annotation "Cannot edit a deleted component") + + (not (or (nil? value) (and (string? value) (<= (count value) 300)))) + (u/not-valid plugin-id :annotation "Expected null or a string of at most 300 characters") + + :else + (st/emit! (-> (dw/update-component-annotation id value) + (se/add-event plugin-id))))))} + + :duplicate + (fn [] + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'library:write' permission") + + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :duplicate "Can only duplicate components in the current file's library") + + (nil? component) + (u/not-valid plugin-id :duplicate "Component does not exist") + + (:deleted component) + (u/not-valid plugin-id :duplicate "Cannot duplicate a deleted component") + + :else + (let [new-id (uuid/next)] + (st/emit! (-> (dwl/duplicate-component file-id id new-id) + (se/add-event plugin-id))) + (lib-component-proxy plugin-id file-id new-id))))) + + :restore + (fn [] + (let [component (u/locate-library-component file-id id)] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :restore "Plugin doesn't have 'library:write' permission") + + (nil? component) + (u/not-valid plugin-id :restore "Component does not exist") + + (:deleted component) + (st/emit! (-> (dwl/restore-component file-id id) + (se/add-event plugin-id)))))) :instance (fn [] @@ -1080,6 +1152,16 @@ (map #(lib-component-proxy plugin-id file-id %)))] (apply array components)))} + :deletedComponents + {:get + (fn [] + (let [components (->> (u/locate-file file-id) + :data + :components + (filter (comp :deleted second)) + (map #(lib-component-proxy plugin-id file-id (first %))))] + (apply array components)))} + :tokens {:this true :get @@ -1206,6 +1288,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 +1318,45 @@ :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))))} + + :tokensSource + {:get + (fn [] + (let [file-id (:current-file-id @st/state) + file-data (ctf/file-data (u/locate-file file-id))] + (library-proxy plugin-id (cfo/get-effective-tokens-source file-data)))) + :set + (fn [value] + (let [file-id (:current-file-id @st/state) + library-id (when (library-proxy? value) (obj/get value "$id")) + library (some-> library-id u/locate-file) + library-data (some-> library ctf/file-data) + file-data (ctf/file-data (u/locate-file file-id))] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :tokensSource "Plugin doesn't have 'content:write' permission") + + (not (library-proxy? value)) + (u/not-valid plugin-id :tokensSource value) + + (nil? library) + (u/not-valid plugin-id :tokensSource "Library is not connected") + + (and (not= library-id file-id) + (not (cfo/tokens-provider? library-data))) + (u/not-valid plugin-id :tokensSource "Library must have its own tokens") + + (not (cfo/effective-tokens-source? file-data library-id)) + (st/emit! (-> (dwtl/set-tokens-source library-id) + (se/add-event plugin-id))))))} :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 +1398,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/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 6be18bd4fa..89cfdd6daf 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 per-side-stroke-attrs-restricted? per-side-stroke-widths-message token-attr? valid-token-resolution?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] @@ -292,7 +292,7 @@ (and (not (ctt/per-side-stroke-shape? (:type (u/proxy->shape self)))) (some #(= :multiple (types.stroke/width-type %)) value)) - (u/not-valid plugin-id :strokes "Per-side stroke widths require a board or rectangle") + (u/not-valid plugin-id :strokes per-side-stroke-widths-message) :else (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value) options)))))) @@ -1587,6 +1587,38 @@ target-id true))))) + :updateMain + (fn [] + (let [shape (u/locate-shape file-id page-id id) + objects (u/locate-objects file-id page-id) + component (when (:component-id shape) + (u/locate-library-component (:component-file shape) (:component-id shape)))] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :updateMain "Plugin doesn't have 'content:write' permission") + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :updateMain "Plugin doesn't have 'library:write' permission") + + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :updateMain "Can only update a component from a copy in the current file") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :updateMain "Cannot modify a page that is not currently active") + + (not (and (ctk/instance-head? shape) (ctk/in-component-copy? shape))) + (u/not-valid plugin-id :updateMain "The shape is not a component copy head") + + (u/inside-component-copy? objects shape) + (u/not-valid plugin-id :updateMain "Cannot update the main from a shape inside another component copy") + + (or (nil? component) (:deleted component)) + (u/not-valid plugin-id :updateMain "The main component does not exist or is deleted") + + :else + (st/emit! (-> (dwl/update-component-sync id (:component-file shape)) + (se/add-event plugin-id)))))) + :resetOverrides (fn [] (let [shape (u/locate-shape file-id page-id id)] @@ -1808,6 +1840,11 @@ (some #(not (token-attr? %)) kw-attrs) (u/not-valid plugin-id :applyToken attrs) + ;; The uniform `strokeWidth` alias is exempt: it expands + ;; to a uniform width on every side. + (per-side-stroke-attrs-restricted? file-id page-id [id] attrs) + (u/not-valid plugin-id :applyToken per-side-stroke-widths-message) + :else (st/emit! (-> (dwta/toggle-token {:token token diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ae0f6bf171..62573c232f 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -11,7 +11,6 @@ [app.common.files.tokens :as cfo] [app.common.json :as json] [app.common.schema :as sm] - [app.common.types.file :as ctf] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.common.types.tokens-status :as ctos] @@ -112,6 +111,20 @@ (or (= attr stroke-width-alias) (cto/token-attr? attr)))) +(def per-side-stroke-widths-message + "Per-side stroke widths require a board or rectangle") + +(defn per-side-stroke-attrs? + "Returns true when the plugin attrs name at least one explicit per-side stroke + width attribute (`strokeWidthTop/Right/Bottom/Left`). The uniform + `strokeWidth` alias is not considered per-side: it expands to all four sides + with the same token and always resolves to a uniform width." + [attrs] + (boolean + (some #(contains? cto/per-side-stroke-width-keys + (token-attr-plugin->token-attr %)) + attrs))) + (defn- token-name-schema [file-id set-id token] (let [tokens-lib (u/locate-tokens-lib file-id) @@ -182,23 +195,41 @@ (get (:name token)) (resolved-without-errors?)))) +(defn per-side-stroke-attrs-restricted? + "Returns true when `attrs` name an explicit per-side stroke width while some + target shape on `page-id` does not support independent sides. Only boards and + rectangles accept them. Shapes absent from the page are ignored, because + token application only touches the active page." + [file-id page-id shape-ids attrs] + (when (per-side-stroke-attrs? attrs) + (boolean + (some (fn [id] + (let [shape (u/locate-shape file-id page-id id)] + (and (some? shape) + (not (cto/per-side-stroke-shape? (:type shape)))))) + shape-ids)))) + (defn- apply-token-to-shapes - [plugin-id file-id set-id id shape-ids attrs] + [plugin-id code file-id set-id id shape-ids attrs] (cond (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id code "Plugin doesn't have 'content:write' permission") :else (let [token (u/locate-token file-id set-id id)] (cond (nil? token) - (u/not-valid plugin-id :applyToSelected id) + (u/not-valid plugin-id code id) (not (valid-token-resolution? file-id set-id id)) - (u/not-valid plugin-id :applyToSelected (:value token)) + (u/not-valid plugin-id code (:value token)) (some #(not (token-attr? %)) attrs) - (u/not-valid plugin-id :applyToSelected attrs) + (u/not-valid plugin-id code attrs) + + (per-side-stroke-attrs-restricted? file-id (:current-page-id @st/state) + shape-ids attrs) + (u/not-valid plugin-id code per-side-stroke-widths-message) :else (st/emit! @@ -233,14 +264,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 @@ -250,41 +279,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 rather than leaking the raw tokenscript structure." - [resolved-value] - (let [v (ts/tokenscript-symbols->penpot-unit resolved-value)] - (cond - (nil? v) nil - (sequential? v) (clj->js v) - :else #js [v]))) - (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")) @@ -444,14 +461,15 @@ [:vector [:fn shape-proxy?]] [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] - (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} + (apply-token-to-shapes plugin-id :applyToShapes file-id set-id id + (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false :schema [:tuple [:maybe [::sm/set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] - (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) + (apply-token-to-shapes plugin-id :applyToSelected file-id set-id id selected attrs)))})) ;; === Token Set @@ -512,8 +530,11 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :active "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else - (st/emit! (dwtl/set-enabled-token-set id value))))} ;; This can be done even with tokens in an external library + (st/emit! (dwtl/set-enabled-token-set id value))))} :toggleActive (fn [] @@ -521,6 +542,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :toggleActive "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/toggle-token-set id)))) @@ -737,6 +761,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :active "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/set-token-theme-active id value))))} @@ -746,6 +773,9 @@ (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + (not (dwtl/editable-tokens-status? @st/state file-id)) + (u/not-valid plugin-id :toggleActive "Cannot change activation through a connected library; use penpot.library.local.tokens") + :else (st/emit! (dwtl/toggle-token-theme-active id)))) @@ -839,8 +869,7 @@ :enumerable false :get (fn [] - (let [file (u/locate-file file-id)] - (cfo/editable-tokens? (ctf/file-data file))))} + (dwtl/editable-tokens? @st/state file-id))} :themes {:this true diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index fc6a921799..22f57d45fd 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -18,6 +18,7 @@ [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.util.object :as obj] [cuerdas.core :as str])) @@ -288,9 +289,8 @@ (defn check-editable-tokens [file-id] - (let [file (locate-file file-id)] - (when-not (cfo/editable-tokens? (ctf/file-data file)) - (throw (js/Error. (dm/str "[PENPOT PLUGIN] Cannot modify tokens in an external library")))))) + (when-not (dwtl/editable-tokens? @st/state file-id) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Cannot modify tokens in an external library"))))) (defn throw-validation-errors? [plugin-id] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f9852a1ae6..8a4ad7d427 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] @@ -2942,8 +2960,23 @@ (mem/free) {:x x :y y :width w :height h}))))) +(defn export-target + [shape-id scale format] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id)] + (case (h/call wasm/internal-module "_export_target" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format)) + 0 :client + 2 :too-large + :exporter)))) + (defn render-shape-pdf - [shape-id scale] + [shape-id] (when (initialized?) (let [buffer (uuid/get-u32 shape-id) @@ -2952,8 +2985,7 @@ (aget buffer 0) (aget buffer 1) (aget buffer 2) - (aget buffer 3) - scale) + (aget buffer 3)) heap (mem/get-heap-u8) heapu32 (mem/get-heap-u32) @@ -2962,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?) @@ -2981,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/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index c7e529ec6f..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))) @@ -838,6 +843,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..bbc5b5d512 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -126,3 +126,65 @@ (update-in content [:children 0 :children (+ (:para start) idx)] restyle attrs)) content (rest (map-indexed vector fragment))))) + +;; --- 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 + 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-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) + + (some? (:fill-image %)) + (update :fill-image move-image from to)) + fills)))) + +(defn- sized? + [{{:keys [width height]} :box}] + (and (pos? width) (pos? height))) + +(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)) + (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/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/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index f0f76bee6b..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) @@ -291,23 +316,31 @@ (.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." [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/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/impl.cljs b/frontend/src/app/worker/impl.cljs index 5461756ff5..c7f65dc1b7 100644 --- a/frontend/src/app/worker/impl.cljs +++ b/frontend/src/app/worker/impl.cljs @@ -36,6 +36,9 @@ (when-let [version (get config :version)] (set! cf/version version)) + (when-let [version-tag (get config :version-tag)] + (set! cf/version-tag version-tag)) + (when-let [build-date (get config :build-data)] (set! cf/build-date build-date)) 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..bed81a00e9 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)) + (rx/error! subs (ex-info "unable to init wasm canvas" {:file-id file-id}))) 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/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index 00eaa7612c..d0d2ba677c 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -10,15 +10,76 @@ [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] + [app.main.data.notifications :as ntf] [app.main.data.project :as dpj] [app.main.data.websocket :as dws] [app.main.repo :as rp] [app.main.router :as rt] + [app.util.i18n :as i18n] [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])) +(defn- tr-args-for-team-org-toast + "Runs handle-change-team-organization and returns recorded i18n/tr calls." + [notification organization] + (let [team-id (uuid/next) + current-url (str "https://penpot.example.com/#/dashboard/recent?team-id=" team-id) + state {:current-team-id team-id + :teams {team-id {:id team-id :name "Team-Apple"}}} + event (dcm/handle-change-team-organization + {:team {:id team-id :organization organization} + :notification notification}) + tr-calls (atom [])] + (-> (mock/with-mocks* + {cf/flags (conj cf/flags :admin-console) + ;; plain variadic fn: mock/stub does not cover IFn variadic dispatch + i18n/tr (fn [key & args] + (swap! tr-calls conj [key (vec args)]) + (str key ":" (pr-str args))) + rp/cmd! (mock/stub + (fn [cmd params] + (if (= [:check-nitrate-sso + {:team-id team-id :url current-url}] + [cmd params]) + (rx/of {:authorized true}) + (rx/throw (ex-info "unexpected RPC" {:cmd cmd :params params}))))) + rt/get-current-href (mock/stub (constantly current-url))} + (await (async/observe (ptk/watch event state nil) + :on-next (fn [ev] + (t/is (= ::ntf/show (ptk/type ev))))))) + (.then (fn [_] @tr-calls))))) (t/deftest ^:async team-org-change-toast-names-team-when-joining-org + (let [tr-calls (await (tr-args-for-team-org-toast + "dashboard.team-belong-organization" + {:id (uuid/next) :name "OrgB"}))] + (t/is (= [["dashboard.team-belong-organization" ["Team-Apple" "OrgB"]]] + tr-calls)))) + +(t/deftest ^:async team-org-change-toast-names-team-when-leaving-org + (let [tr-calls (await (tr-args-for-team-org-toast + "dashboard.team-no-longer-belong-organization" + {:name "OrgA"}))] + (t/is (= [["dashboard.team-no-longer-belong-organization" ["Team-Apple" "OrgA"]]] + tr-calls)))) + +(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 @@ -52,7 +113,6 @@ (fn [] (done'))))) done)))) - (t/deftest organization-sso-activation-redirects-current-team (t/async done (let [team-id (uuid/next) diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index d6b4f36a86..4673b13411 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -8,17 +8,23 @@ (: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] + [frontend-tests.helpers.async :as async] [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 @@ -34,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"))))) @@ -55,7 +72,7 @@ (rx/of {:filename "export.png" :mtype "image/png" :uri "blob:export"}))) - dwp/force-persist-and-wait (mock/stub (fn [_] (rx/of ::force-persisted))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/of ::force-persisted))) dom/trigger-download-uri (mock/stub (fn [& _] nil))} (fn [done'] (let [completed (fn [_state] @@ -97,3 +114,343 @@ (de/request-multiple-export {:exports exports}) :the/end))) done)))) + +(defn- export-store + "A store for the export tests that records store errors in `store-errors`." + [state store-errors] + (ptk/store {:state state :on-error #(swap! store-errors conj %)})) + +(defn- unsaved-state + "Test state whose save queue still holds a change of `file-id`." + [file-id] + (let [commit-id (uuid/next)] + (assoc (test-state) + :persistence {:queue (conj #queue [] commit-id) + :index {commit-id {:id commit-id :file-id file-id}}}))) + +;; Scenario: the exporter fails a single export. Proves: the user gets a +;; toast and stays in the workspace; the failure is not raised to the global +;; error handler, which would replace the workspace with an error page. +(t/deftest ^:async failed-simple-export-shows-a-toast-instead-of-raising + (let [store-errors (atom []) + flashed (atom []) + store (export-store (test-state) store-errors)] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "exporter failed" + {:type :server-error + :code :unhandled})))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + errors/flash (fn [& {:keys [hint type]}] + (swap! flashed conj [hint type])) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export export})) + (await (async/wait-for #(seq @flashed) "the failure is shown")) + (await (async/settle)) + (t/is (= [["translated:errors.export-failed" :unhandled]] @flashed) + "an exporter defect reports as unhandled") + (t/is (empty? @store-errors) "nothing reaches the global error handler") + (t/is (nil? (:export @store)) "the export state is cleared"))))) + +;; Scenario: the export request of a multiple export fails. Proves: the user +;; gets a toast, and the leave-page warning the export turned on is released +;; even though no progress message will ever arrive. +(t/deftest ^:async failed-multiple-export-shows-a-toast-and-releases-the-leave-warning + (let [store-errors (atom []) + flashed (atom []) + tasks (atom #{}) + store (export-store (test-state) store-errors)] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/throw (ex-info "exporter failed" + {:type :server-error})))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + ws/get-rcv-stream (mock/stub (fn [_] (rx/subject))) + st/ongoing-tasks tasks + errors/flash (fn [& {:keys [hint]}] (swap! flashed conj hint)) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-multiple-export {:exports [(assoc export :enabled true)]})) + (await (async/wait-for #(seq @flashed) "the failure is shown")) + (await (async/wait-for #(not (contains? @tasks :export)) + "the leave-page warning is released")) + (t/is (= ["translated:errors.export-failed"] @flashed)) + (t/is (empty? @store-errors) "nothing reaches the global error handler"))))) + +;; Scenario: an export starts while its file has a change not saved yet. +;; Proves: the user is warned that the export may miss it, and the export +;; runs anyway. +(t/deftest ^:async export-warns-when-the-file-has-unsaved-changes + (let [file-id (uuid/next) + downloaded (atom false) + store (export-store (unsaved-state file-id) (atom []))] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/of {:filename "export.png" + :mtype "image/png" + :uri "blob:export"}))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + dom/trigger-download-uri (mock/stub (fn [& _] (reset! downloaded true))) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id file-id)})) + (await (async/wait-for #(true? @downloaded) "the export runs anyway")) + (t/is (= "translated:workspace.export.unsaved-changes" + (get-in @store [:notification :content]))) + (t/is (= :warning (get-in @store [:notification :level]))))))) + +;; Scenario: an export starts with every change of its file saved. Proves: +;; no warning is shown. +(t/deftest ^:async export-shows-no-warning-when-the-file-is-saved + (let [downloaded (atom false) + store (export-store (test-state) (atom []))] + (await + (mock/with-mocks* + {repo/cmd! (mock/stub (fn [_ _] + (rx/of {:filename "export.png" + :mtype "image/png" + :uri "blob:export"}))) + dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + dom/trigger-download-uri (mock/stub (fn [& _] (reset! downloaded true)))} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id (uuid/next))})) + (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/data/exports_files_test.cljs b/frontend/test/frontend_tests/data/exports_files_test.cljs index a979ac34d7..fc648c3b55 100644 --- a/frontend/test/frontend_tests/data/exports_files_test.cljs +++ b/frontend/test/frontend_tests/data/exports_files_test.cljs @@ -5,18 +5,23 @@ ;; 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." + "Exporting files as jobs (one file, one job and one answer, with the progress + of the job reported while it runs), and opening the `.penpot` download dialog + (the pending changes are sent first, and the dialog is told when some are + still not saved)." (:require [app.common.uuid :as uuid] [app.main.data.exports.files :as fexp] + [app.main.data.modal :as modal] + [app.main.data.persistence :as dps] [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])) + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) (defn- file [name] @@ -284,3 +289,59 @@ (await done) (t/is (= {:file-id (:id target) :cancelled true} (last @seen))) (t/is (not-any? :uri @seen)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; DOWNLOAD DIALOG +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- file-param + [file-id] + {:id file-id :name "File" :project-id (uuid/next) :is-shared false}) + +(defn- with-queued-change + "Adds to `state` a save queue that still holds a change of `file-id`." + [state file-id] + (let [commit-id (uuid/next)] + (assoc state :persistence {:queue (conj #queue [] commit-id) + :index {commit-id {:id commit-id :file-id file-id}}}))) + +(defn- ^:async dialog-props + "Opens the download dialog for `file-id` from `state` and returns the + props the dialog is shown with, plus the file ids the save wait got." + [state file-id] + (let [store (ptk/store {:state state :on-error #(t/is false (str %))}) + waited (atom nil)] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of false))) + dps/force-persist-files-and-wait (mock/stub (fn [file-ids _] + (reset! waited file-ids) + (rx/empty)))} + (ptk/emit! store (fexp/open-export-dialog [(file-param file-id)])) + (await (hva/wait-for #(some? (get-in @store [::modal/modal :props])) + "the dialog opens")))) + (assoc (get-in @store [::modal/modal :props]) ::waited @waited))) + +;; Scenario: the user downloads a file whose changes are all saved. Proves: +;; the pending changes of that file are sent first, and the dialog shows no +;; warning. +(t/deftest ^:async download-of-a-saved-file-has-no-warning + (let [file-id (uuid/next) + props (await (dialog-props {} file-id))] + (t/is (= #{file-id} (::waited props)) "the save wait covers the downloaded file") + (t/is (false? (:unsaved props))))) + +;; Scenario: the file still has a change the server does not have after the +;; wait. Proves: the dialog is told, so it can warn that the download will +;; not include it. +(t/deftest ^:async download-of-a-file-with-unsaved-changes-warns + (let [file-id (uuid/next) + props (await (dialog-props (with-queued-change {} file-id) file-id))] + (t/is (true? (:unsaved props))))) + +;; Scenario: another file has unsaved changes. Proves: only the downloaded +;; file's changes count. +(t/deftest ^:async unsaved-changes-of-another-file-do-not-warn + (let [file-id (uuid/next) + props (await (dialog-props (with-queued-change {} (uuid/next)) file-id))] + (t/is (false? (:unsaved props))))) diff --git a/frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs b/frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs index 4592c90ab3..b2b5c2e083 100644 --- a/frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs +++ b/frontend/test/frontend_tests/data/fix_deleted_fonts_test.cljs @@ -2,7 +2,7 @@ ;; 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 +;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns frontend-tests.data.fix-deleted-fonts-test (:require diff --git a/frontend/test/frontend_tests/data/modal_test.cljs b/frontend/test/frontend_tests/data/modal_test.cljs new file mode 100644 index 0000000000..e3b0fd4325 --- /dev/null +++ b/frontend/test/frontend_tests/data/modal_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.data.modal-test + (:require + [app.common.uuid :as uuid] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [cljs.test :as t :include-macros true])) + +(t/deftest export-shapes-modal-event-carries-a-summary + (let [shape-a (uuid/next) + shape-b (uuid/next) + exports [{:object-id shape-a :type :png} + {:object-id shape-a :type :svg} + {:object-id shape-b :type :png}] + event (modal/show :export-shapes + {:exports exports + :origin "workspace:sidebar" + :name "Page 1"}) + data (ev/-data event)] + + (t/testing "the raw exports never leak into the event" + (t/is (not (contains? data :exports)))) + + (t/testing "the event carries counters instead" + (t/is (= 3 (:num-exports data))) + (t/is (= 2 (:num-shapes data))) + (t/is (= 2 (:png data))) + (t/is (= 1 (:svg data)))) + + (t/testing "the modal context is preserved" + (t/is (= "Page 1" (:page data))) + (t/is (= :export-shapes (:name data)))) + + (t/testing "origin is emitted as event metadata, not as a prop" + (t/is (= "workspace:sidebar" (::ev/origin data))) + (t/is (not (contains? data :origin)))))) + +(t/deftest other-modals-keep-their-props + (let [event (modal/show :some-modal {:foo "bar" :items [1 2 3]}) + data (ev/-data event)] + (t/is (= "bar" (:foo data))) + (t/is (= [1 2 3] (:items data))) + (t/is (= :some-modal (:name data))))) + +(t/deftest modal-origin-becomes-event-metadata + (let [event (modal/show :some-modal {:foo "bar" :origin "dashboard"}) + data (ev/-data event)] + (t/is (= "dashboard" (::ev/origin data))) + (t/is (not (contains? data :origin))) + (t/is (= "bar" (:foo data))))) diff --git a/frontend/test/frontend_tests/data/persistence_retry_test.cljs b/frontend/test/frontend_tests/data/persistence_retry_test.cljs index c2ddab917e..1eba1bd1b5 100644 --- a/frontend/test/frontend_tests/data/persistence_retry_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_retry_test.cljs @@ -2,7 +2,7 @@ ;; 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 +;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns frontend-tests.data.persistence-retry-test "Retry-episode tests for the file persistence save pipeline @@ -83,18 +83,26 @@ (rx/dispose! store) (rx/end! response)))))) +(defn- instant-backoff + "Timer double: the backoff delays fire at once and the slow cycle never + does, so a failure that outlasts the backoff settles in `:error`." + [ms] + (if (= ms dps/slow-retry-delay-ms) + (rx/subject) + (rx/of :tick))) + (defn- check-failed-save-response "Feeds `result` as the transport response and asserts the commit stays queued as a failed save instead of being treated as persisted. Retry - timers fire instantly (stubbed `rx/timer`), so a permanently bad answer - exhausts the 3-retry budget and lands terminal: `:error` carrying + timers fire instantly (stubbed `rx/timer`, slow cycle held), so a + permanently bad answer exhausts the 3-retry budget and lands terminal: `:error` carrying `:invalid-save-response`, queue intact, exactly 4 sends." [result] (with-persistence (^:async fn [{:keys [file-id requests store]}] (await (mock/with-mocks* - {rx/timer (mock/stub (fn [_] (rx/of :tick)))} + {rx/timer (mock/stub instant-backoff)} (ptk/emit! store (local-commit file-id) ::dps/force-persist) (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) (= :invalid-save-response @@ -141,7 +149,7 @@ (^:async fn [{:keys [file-id requests store]}] (await (mock/with-mocks* - {rx/timer (mock/stub (fn [_] (rx/of :tick)))} + {rx/timer (mock/stub instant-backoff)} (ptk/emit! store (local-commit file-id) ::dps/force-persist) (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) (empty? (get-in @store [:persistence :queue]))) @@ -158,6 +166,101 @@ (rx/throw (ex-info "offline" {:type :offline})) (rx/of {:revn 1}))))))) +(defn- check-gateway-response-retries + "Answers the first send with a proxy error page for `status`, built by the + real `rp/handle-response`, and the retry with a valid revision. The save + must take the retry path and land, never halt in `:error`." + [status] + (let [calls (atom 0)] + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + (str "a " status " is retried and the save lands"))) + (t/is (= 2 (count @requests)) "the proxy error plus one retry") + (t/is (empty? @failures) "a retried proxy error never reaches the failure toast")))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rp/handle-response {:status status + :headers {"content-type" "text/html"} + :body "Gateway Timeout" + :uri "https://penpot.example.com/api/main/methods/update-file"}) + (rx/of {:revn 1})))))) + +;; Variant: the proxy in front of the backend timed out. +(t/deftest ^:async gateway-timeout-save-is-retried + (await (check-gateway-response-retries 504))) + +;; Variant: Cloudflare gave up waiting for the origin. +(t/deftest ^:async cloudflare-origin-timeout-save-is-retried + (await (check-gateway-response-retries 524))) + +(defn- check-busy-server-response-retries + "Answers the first send with `response`, a refusal the backend wrote + because it was busy, built by the real `rp/handle-response`, and the retry + with a valid revision. The save was never applied, so it must take the + retry path and land, never halt in `:error`." + [response] + (let [calls (atom 0)] + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + (str "a busy " (:status response) " is retried and the save lands"))) + (t/is (= 2 (count @requests)) "the busy answer plus one retry") + (t/is (empty? @failures) "a retried busy answer never reaches the failure toast")))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rp/handle-response (assoc response :uri "https://penpot.example.com/api/main/methods/update-file")) + (rx/of {:revn 1})))))) + +;; Variant: the save waited too long for a concurrency slot (no body). +(t/deftest ^:async concurrency-limit-save-is-retried + (await (check-busy-server-response-retries {:status 429 :body nil}))) + +;; Variant: the database cancelled the save while it waited for the file lock. +(t/deftest ^:async statement-timeout-save-is-retried + (await (check-busy-server-response-retries + {:status 504 + :body {:type :server-error + :code :statement-timeout + :hint "The operation took too long and was cancelled"}}))) + +;; Variant: the database ended the save's transaction after it sat idle. +(t/deftest ^:async idle-transaction-timeout-save-is-retried + (await (check-busy-server-response-retries + {:status 504 + :body {:type :server-error + :code :idle-in-transaction-timeout + :hint "The transaction was idle too long and was cancelled"}}))) + +;; Scenario: the backend fails the save for any other reason. Proves: only +;; the busy-server codes retry; a server defect stops saving at once. +(t/deftest ^:async other-server-errors-stay-terminal + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub instant-backoff)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails")) + (t/is (= :server-error (get-in @store [:persistence :error :cause-type]))) + (t/is (= 1 (count @requests)) "no retry")))) + (fn [_ _] + (rp/handle-response {:status 500 + :body {:type :server-error :code :unhandled} + :uri "https://penpot.example.com/api/main/methods/update-file"}))))) + ;; Scenario: every send fails transiently. The 2s / 8s / 20s retries fire ;; and then the failure falls through to the exact terminal path: `:error` ;; carrying the cause, queue intact, one flash. Proves: the budget bounds @@ -169,7 +272,7 @@ (^:async fn [{:keys [file-id failures requests store]}] (await (mock/with-mocks* - {rx/timer (mock/stub (fn [ms] (swap! delays conj ms) (rx/of :tick)))} + {rx/timer (mock/stub (fn [ms] (swap! delays conj ms) (instant-backoff ms)))} (ptk/emit! store (local-commit file-id) ::dps/force-persist) (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) (= 4 (count @requests))) @@ -177,7 +280,8 @@ (t/is (= :error (get-in @store [:persistence :status]))) (t/is (= :save-failed (get-in @store [:persistence :error :code]))) (t/is (= 1 (count (get-in @store [:persistence :queue])))) - (t/is (= [2000 8000 20000] @delays) "the backoff schedule fires in order") + (t/is (= [2000 8000 20000 dps/slow-retry-delay-ms] @delays) + "the backoff schedule fires in order, then the slow cycle is armed") (t/is (= 1 (count @failures)) "exhaustion flashes exactly once")))) (fn [_ _] (rx/throw (ex-info "offline" {:type :offline}))))))) @@ -221,9 +325,9 @@ (rx/subject))))))) ;; Scenario: a persist-commit arrives with a superseded episode token after -;; a transient failure. Without the token guard it would fail terminally as -;; `:save-outcome-unknown`; with it, nothing happens. Proves: stale retry -;; timers stay silent. +;; a transient failure. Without the token guard it would send outside the +;; backoff schedule; with it, nothing happens. Proves: stale retry timers +;; stay silent. (t/deftest ^:async stale-retry-token-stays-silent (await (with-persistence @@ -333,7 +437,7 @@ (^:async fn [{:keys [file-id store]}] (await (mock/with-mocks* - {rx/timer (mock/stub (fn [_] (rx/of :tick)))} + {rx/timer (mock/stub instant-backoff)} (ptk/emit! store (local-commit file-id) ::dps/force-persist) (await (async/wait-for #(= :error (get-in @store [:persistence :status])) "retries exhaust into terminal error")) @@ -503,7 +607,7 @@ (^:async fn [{:keys [file-id requests store]}] (await (mock/with-mocks* - {rx/timer (mock/stub (fn [_] (rx/of :tick))) + {rx/timer (mock/stub instant-backoff) rx/timeout (mock/stub (fn [ms with ob] (swap! timeouts conj ms) (timeout 0 with ob)))} @@ -519,3 +623,324 @@ (if (= 1 (swap! calls inc)) (rx/subject) (rx/of {:revn 1}))))))) + +;; --------------------------------------------------------------------------- +;; slow cycle +;; --------------------------------------------------------------------------- + +(defn- slow-cycle-timer + "Timer double: the backoff delays fire at once, and every slow cycle waits + on a subject collected in `cycles`, so the test fires them one by one." + [cycles] + (fn [ms] + (if (= ms dps/slow-retry-delay-ms) + (let [cycle-s (rx/subject)] + (swap! cycles conj cycle-s) + cycle-s) + (rx/of :tick)))) + +;; Scenario: the backoff is spent while the network is down. The queue keeps +;; trying on the slow cycle, one send per cycle, without warning the user +;; again, and saves once the network is back. Proves: a session outlasting +;; the backoff still recovers on its own. +(t/deftest ^:async a-spent-backoff-keeps-trying-on-a-slow-cycle + (let [calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id failures requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count @cycles))) + "the spent backoff arms the slow cycle")) + (t/is (= 4 (count @requests))) + (t/is (= 1 (count @failures))) + + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(= 2 (count @cycles)) + "a failed slow attempt arms the next cycle")) + (t/is (= 5 (count @requests)) "a slow cycle sends once") + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 1 (count @failures)) "the user is not warned again") + + ;; The warning the first failure left on screen. + (ptk/emit! store #(assoc % :notification {:tag errors/persistence-failed-tag + :level :error})) + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the slow attempt saves")) + (t/is (= 6 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "every attempt carries the same commit id") + (t/is (nil? (get-in @store [:persistence :recovering]))) + (t/is (nil? (get-in @store [:persistence :failing-since]))) + (t/is (nil? (get @store :notification)) + "the failure warning goes away once the save lands")))) + (fn [_ _] + (if (< (swap! calls inc) 6) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Scenario: a gateway error outlasts the backoff, then the proxy recovers. +;; Proves: each gateway status takes the slow cycle and saves the held edit. +(t/deftest ^:async a-gateway-error-arms-the-slow-cycle + (doseq [status [504 520 521 522 523 524]] + (let [calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @cycles)) + (str status " arms the slow cycle"))) + (t/is (= 4 (count @requests))) + (t/is (= :gateway-error (get-in @store [:persistence :error :cause-type]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + (str status " recovers on the slow cycle"))) + (t/is (= 5 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "the slow cycle saves the held commit")))) + (fn [_ _] + (if (<= (swap! calls inc) 4) + (rp/handle-response {:status status :body "Gateway error" :uri "/api"}) + (rx/of {:revn 1})))))))) + +;; Scenario: a save fails for a reason no attempt can get past. Proves: only +;; transport failures arm the slow cycle. +(t/deftest ^:async a-terminal-failure-does-not-arm-the-slow-cycle + (let [cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails")) + (await (async/settle)) + (t/is (= 1 (count @requests))) + (t/is (empty? @cycles))))) + (fn [_ _] (rx/throw (ex-info "Validation failed" {:type :validation}))))))) + +;; Scenario: a gateway answers 504 only after its own timeout, while the +;; backend may still be applying the save. Proves: slow failures below the +;; limit still arm the slow cycle; at the limit the save stops being sent, +;; and opening a file releases it instead of sending it again. +(t/deftest ^:async slow-failures-stop-the-slow-cycle-at-the-limit + (doseq [[slow-count armed?] [[(dec dps/max-slow-failures) true] + [dps/max-slow-failures false]]] + (let [clock (atom 0) + calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles)) + ct/now (mock/stub #(ct/inst @clock))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + (str slow-count " slow failures spend the backoff"))) + (await (async/settle)) + (t/is (= 4 (count @requests))) + (t/is (= slow-count (get-in @store [:persistence :slow-failures]))) + (t/is (= (if armed? 1 0) (count @cycles)) + (str slow-count " slow failures " (if armed? "arm" "do not arm") " the slow cycle")) + + (when-not armed? + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(empty? (get-in @store [:persistence :queue])) + "recovery releases the held edit")) + (t/is (= 4 (count @requests)) "the released edit is not sent again"))))) + (fn [_ _] + (when (<= (swap! calls inc) slow-count) + (swap! clock + dps/slow-failure-ms)) + (rp/handle-response {:status 504 :body "Gateway timeout" :uri "/api"}))))))) + +;; Scenario: the slow cycle is still failing when the retry window closes. +;; Proves: the cycle gives up, keeping the edits queued. +(t/deftest ^:async the-slow-cycle-stops-when-the-retry-window-closes + (let [clock (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles)) + ct/now (mock/stub #(ct/inst @clock))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @cycles)) "the slow cycle is armed")) + + (reset! clock (+ dps/retry-give-up-ms 1000)) + (rx/push! (last @cycles) :tick) + (await (async/wait-for #(= 5 (count @requests)) "the last slow attempt")) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the attempt fails")) + (await (async/settle)) + (t/is (= 1 (count @cycles)) "no further cycle is armed") + (t/is (= 1 (count (get-in @store [:persistence :queue]))))))) + (fn [_ _] (rx/throw (ex-info "offline" {:type :offline}))))))) + +;; Scenario: the backoff is spent and the user leaves the workspace, which +;; stops the slow cycle, then opens a file again. Recovery sends the queue +;; once more instead of releasing it, and it saves. Proves: edits held by a +;; transport failure are never dropped by recovery. +(t/deftest ^:async recovery-resends-a-transport-failure-instead-of-releasing-it + (let [calls (atom 0) + cycles (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @cycles)) "the slow cycle is armed")) + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "recovery saves the held edit")) + (t/is (= 5 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "recovery sends the same commit, not a released queue")))) + (fn [_ _] + (if (< (swap! calls inc) 5) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Leaving the workspace tests. + +;; Scenario: a save fails on a transport error and the user leaves the +;; workspace before the backoff fires. The pending retry stays silent, and +;; opening a file sends the held edit again. Proves: the backoff does not +;; run outside the workspace and resumes once a file is opened. +(t/deftest ^:async leaving-the-workspace-stops-the-backoff + (let [calls (atom 0) + timers (atom [])] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [_] + (let [timer-s (rx/subject)] + (swap! timers conj timer-s) + timer-s)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @timers)) "the failure schedules a retry")) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (async/wait-for #(get-in @store [:persistence :suspended]) + "leaving the workspace suspends retries")) + (rx/push! (first @timers) :tick) + (await (async/settle)) + (t/is (= 1 (count @requests)) "the pending retry stays silent") + (t/is (= :retrying (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/workspace-initialized)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "opening a file saves the held edit")) + (t/is (= 2 (count @requests))) + (t/is (apply = (map (comp :commit-id second) @requests)) + "the same commit is sent again") + (t/is (nil? (get-in @store [:persistence :suspended])))))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + (rx/throw (ex-info "offline" {:type :offline})) + (rx/of {:revn 1}))))))) + +;; Scenario: the user leaves the workspace while a save is in flight, and the +;; save then fails on a transport error. No backoff is scheduled, the online +;; event does not resend, and opening a file does. Proves: a failure that +;; lands outside the workspace waits for the user instead of retrying there. +(t/deftest ^:async a-failure-after-leaving-the-workspace-arms-no-retry + (let [calls (atom 0) + timers (atom []) + first-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (fn [ms] (swap! timers conj ms) (rx/subject)))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "the edit is sent")) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (async/wait-for #(get-in @store [:persistence :suspended]) + "leaving the workspace suspends retries")) + (rx/error! first-s (ex-info "offline" {:type :offline})) + (await (async/wait-for #(= :retrying (get-in @store [:persistence :status])) + "the failure keeps the edit queued")) + (await (async/settle)) + (t/is (empty? @timers) "no backoff is scheduled") + + (ptk/emit! store (#'dps/resume-on-online)) + (await (async/settle)) + (t/is (= 1 (count @requests)) "the online event does not resend") + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/workspace-initialized)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "opening a file saves the held edit")) + (t/is (= 2 (count @requests)))))) + (fn [_ _] + (if (= 1 (swap! calls inc)) + first-s + (rx/of {:revn 1}))))))) + +;; Scenario: the last backoff attempt is in flight when the user leaves the +;; workspace, and it fails. The queue halts without arming the slow cycle, +;; and opening a file sends it once more. Proves: the slow cycle never +;; starts outside the workspace. +(t/deftest ^:async a-spent-backoff-outside-the-workspace-arms-no-slow-cycle + (let [calls (atom 0) + cycles (atom []) + last-s (rx/subject)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {rx/timer (mock/stub (slow-cycle-timer cycles))} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 4 (count @requests)) "the last retry is sent")) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (await (async/wait-for #(get-in @store [:persistence :suspended]) + "leaving the workspace suspends retries")) + (rx/error! last-s (ex-info "offline" {:type :offline})) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the spent backoff halts the queue")) + (await (async/settle)) + (t/is (empty? @cycles) "no slow cycle is armed") + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + + (ptk/emit! store (ptk/data-event :app.main.data.workspace/workspace-initialized)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "opening a file saves the held edit")) + (t/is (= 5 (count @requests)))))) + (fn [_ _] + (let [n (swap! calls inc)] + (cond + (< n 4) (rx/throw (ex-info "offline" {:type :offline})) + (= n 4) last-s + :else (rx/of {:revn 1})))))))) diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs index 0f72a59025..de85818c38 100644 --- a/frontend/test/frontend_tests/data/persistence_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -18,11 +18,15 @@ `mock/with-mocks` with an explicit `done` chain." (:require [app.common.time :as ct] + [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.main.data.changes :as dch] + [app.main.data.event :as ev] [app.main.data.persistence :as dps] [app.main.data.render-wasm :as drw] + [app.main.data.workspace :as dw] [app.main.errors :as errors] + [app.main.refs :as refs] [app.main.repo :as rp] [app.main.store :as st] [app.util.i18n :as i18n] @@ -30,8 +34,77 @@ [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] + [okulary.core :as l] [potok.v2.core :as ptk])) +(t/deftest reopening-restores-only-the-retained-edits-of-the-loaded-file + (doseq [[status cause-type vern restored?] + [[:retrying nil 0 true] + [:saving nil 0 true] + [:error :offline 0 true] + [:error :validation 0 false] + [:retrying nil 1 false]]] + (t/testing (str status " / " cause-type " / version " vern) + (let [file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + first-id (uuid/next) + next-id (uuid/next) + other-id (uuid/next) + shape (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0}) + file {:id file-id + :vern vern + :data {:pages [page-id] + :pages-index {page-id {:id page-id :name "Page" + :objects {uuid/zero + (cts/setup-shape {:id uuid/zero :type :frame})}}}}} + rename (fn [name] + {:type :mod-obj :id shape-id :page-id page-id + :operations [{:type :set :attr :name :val name}]}) + pstate {:status status + :error {:cause-type cause-type :commit-id first-id} + :queue (into #queue [] [first-id next-id other-id]) + :index {first-id {:file-id file-id :file-vern 0 + :redo-changes [{:type :add-obj :id shape-id :obj shape + :page-id page-id :parent-id uuid/zero + :frame-id uuid/zero}]} + next-id {:file-id file-id :file-vern 0 + :redo-changes [(rename "Retained rectangle")]} + other-id {:file-id (uuid/next) :file-vern 0 + :redo-changes [(rename "Another file")]}}} + state {:persistence pstate} + loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}}) + state) + restored (get-in loaded [:files file-id :data :pages-index page-id :objects shape-id])] + (t/is (= restored? (some? restored))) + (when restored? + (t/is (= "Retained rectangle" (:name restored)) + "queued edits apply in order and exclude other files")) + (t/is (identical? pstate (:persistence loaded)) + "restoring the editor must preserve the save queue"))))) + +;; Scenario: a queued edit no longer applies to the data the server sent, so +;; replaying it throws. Proves: the file still opens, as the server sent it, +;; and the edits stay queued. +(t/deftest reopening-with-an-edit-that-no-longer-applies-keeps-the-server-file + (let [file-id (uuid/next) + page-id (uuid/next) + commit-id (uuid/next) + data {:pages [page-id] + :pages-index {page-id {:id page-id :name "Page" + :objects {uuid/zero + (cts/setup-shape {:id uuid/zero :type :frame})}}}} + file {:id file-id :vern 0 :data data} + pstate {:status :saving + :queue (into #queue [] [commit-id]) + :index {commit-id {:file-id file-id :file-vern 0 + :redo-changes [{:type :mod-obj :id uuid/zero :page-id page-id + :operations [{:type :no-longer-supported}]}]}}} + loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}}) + {:persistence pstate})] + (t/is (identical? data (get-in loaded [:files file-id :data]))) + (t/is (identical? pstate (:persistence loaded))))) + (defn- local-commit "Builds a synthetic local commit event for `file-id`: a page edit as redo, no undo, from the `:local` source." @@ -430,8 +503,8 @@ ;; - Transport failures and malformed answers (`nil`, empty, bad revision) ;; fail the save but preserve the queue for retry. ;; - Initialization recovers gracefully: dangling runners error instead of -;; skipping, unsent commits are picked up, unknown outcomes are reported -;; without replaying. +;; skipping, unsent commits are picked up, unknown outcomes are sent again +;; under the same commit id. ;; ;; Same async pattern as the watchdog tests: triggers stay bare, every ;; assert block is preceded by `wait-for` on its leading signal (or a bare @@ -477,11 +550,10 @@ (rx/end! response)))))) ;; Scenario: edit permission is revoked with an edit queued. The save fails -;; without sending anything, the queue keeps the edit, and the user is -;; notified once; further edits keep queueing behind the failure instead of -;; replacing it. Proves: permission loss fails safe — no send, no discard, -;; one flash. -(t/deftest ^:async permission-loss-fails-without-discarding-queued-edits +;; without sending anything, the queue keeps the edit and the user is +;; notified once, so `wait-persisted-or-error` still rejects and nothing is +;; snapshotted from unsaved state. Proves: permission loss fails safe. +(t/deftest ^:async permission-loss-retains-the-queue-and-notifies-once (await (with-persistence (^:async fn [{:keys [file-id requests failures store]}] @@ -492,37 +564,76 @@ (= 1 (count (get-in @store [:persistence :queue])))) "permission loss fails the save")) (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (t/is (= 1 (count (get-in @store [:persistence :queue]))) + "the undeliverable edit is retained, so waiters keep rejecting") (t/is (empty? @requests)) - (t/is (= 1 (count @failures))) - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (#'dps/update-status :pending)) - (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) - (= 2 (count (get-in @store [:persistence :queue])))) - "second edit queues behind the failure")) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 2 (count (get-in @store [:persistence :queue])))))))) + (t/is (= 1 (count @failures))))))) -;; Scenario: a request with two queued commits fails terminally at the -;; transport. Both edits stay queued as failed; reinitializing persistence -;; must not replay the dead request. Proves: failed requests preserve the -;; queue and are never retried on initialization. -(t/deftest ^:async failed-request-retains-the-queue-and-is-not-retried-on-initialization +;; Scenario: the same terminal failure, then the user opens a file again. +;; Opening a file releases the edit that can never be saved and settles the +;; editor, instead of leaving it halted until a reload. Proves: the halt does +;; not outlive the visit that caused it. +(t/deftest ^:async opening-a-file-again-releases-a-terminal-failure (await (with-persistence - (^:async fn [{:keys [file-id response requests store]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (local-commit file-id) ::dps/force-persist) - (.error response (ex-info "Validation failed" {:type :validation})) - (await (async/wait-for #(= :error (get-in @store [:persistence :status])) - "failed request errors the save")) - (ptk/emit! store (dps/initialize-persistence)) + (^:async fn [{:keys [file-id requests failures store]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) - (= 2 (count (get-in @store [:persistence :queue])))) - "initialization preserves the failed queue")) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 2 (count (get-in @store [:persistence :queue])))) - (t/is (= 1 (count @requests))))))) + (= 1 (count (get-in @store [:persistence :queue])))) + "the save fails terminally")) + ;; The user acts again: a team-role change is one of the two real + ;; recovery triggers (the other is opening a file, whose event does + ;; real work and is not usable as a bare signal here). + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "re-entering the file settles the queue")) + (t/is (nil? (get-in @store [:persistence :error])) + "the halt is cleared once the user comes back") + (t/is (empty? @requests) + "the edit that cannot be saved is not replayed on re-entry") + (t/is (= 1 (count @failures)) "the user is still notified exactly once"))))) + +;; Scenario: two files have pending edits and the first send fails +;; terminally. Opening a file again releases the failed file, the other file +;; still saves, and the editor settles saved instead of staying halted. +;; Proves: one file that cannot be saved does not block every other file. +(t/deftest ^:async terminal-failure-does-not-block-other-files + (let [files (into [] (repeatedly 2 uuid/next)) + sends (atom 0)] + (await + (with-persistence + (^:async fn [{:keys [requests store]}] + (ptk/emit! store + #(assoc % :files (into (:files %) + (map (fn [id] [id {:id id :revn 0}])) + files)) + (local-commit (nth files 0)) + (local-commit (nth files 1)) + ::dps/force-persist) + (await (async/wait-for #(seq @requests) "the first file is sent")) + (let [failed-file (get-in (second (first @requests)) [:id])] + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (not (empty? (get-in @store [:persistence :queue])))) + "the terminal failure retains the queue")) + ;; The user acts again: recovery releases the failed file and drains + ;; the rest. + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the released file frees the rest of the queue")) + (t/is (= 2 (count @requests)) + "the other file was saved after the failed one was released") + (t/is (= 1 (count (filter #(= failed-file (get-in (second %) [:id])) @requests))) + "the failed file is sent once, never replayed"))) + (fn [_ _] + ;; Fail the first send terminally and answer the rest inline, so no + ;; hot-subject race can swallow the answer. + (if (= 1 (swap! sends inc)) + (rx/throw (ex-info "rejected" {:type :validation :code :invalid-data})) + (rx/of {:revn 1}))))))) (defn- check-save-failure-warning "Fails a queued save with `cause-type` and asserts the hint shown to the @@ -585,6 +696,19 @@ (t/is (not (dps/transient-error? {:type :persistence :code :save-permission-denied}))) (t/is (not (dps/transient-error? {})))) +;; A busy backend refused or rolled back the save, so it is safe to resend: +;; a rate or concurrency limit, or a database timeout. Any other server +;; error stays terminal. +(t/deftest busy-server-classification + (t/is (dps/transient-error? {:type :rate-limit})) + (t/is (dps/transient-error? {:type :persistence :cause-type :rate-limit :code :rate-limited})) + (t/is (dps/transient-error? {:type :server-error :code :statement-timeout})) + (t/is (dps/transient-error? {:type :persistence :cause-type :server-error :code :statement-timeout})) + (t/is (dps/transient-error? {:type :persistence :cause-type :server-error :code :idle-in-transaction-timeout})) + (t/is (not (dps/transient-error? {:type :server-error}))) + (t/is (not (dps/transient-error? {:type :persistence :cause-type :server-error :code :unhandled}))) + (t/is (not (dps/transient-error? {:type :persistence :cause-type :validation :code :statement-timeout})))) + ;; The transient set is owned by `repo/retryable-types`: every transport ;; type retryable at the HTTP layer must also be transient for the save ;; pipeline, in both the direct and the wrapped (`persistence-failed`) @@ -687,35 +811,53 @@ (t/deftest ^:async save-error-does-not-resend-an-active-request (await (check-active-request-not-resent [(ptk/data-event ::dps/error)]))) -;; Scenario: two commits go out, permission is lost mid-flight (first request -;; fails, one edit stays queued as failed), then permission is restored with -;; a team-role change. Only the unsent edit is resent; once answered, the -;; file saves with an empty queue. Proves: restoration resumes exactly the -;; unsent edits. -(t/deftest ^:async permission-restoration-resumes-only-unsent-edits +;; Scenario: permission is lost mid-flight, so the save fails terminally. +;; Even if the permission comes back, recovery releases the undeliverable +;; edits instead of replaying them. The backend raises `:not-found` for a +;; lost edit permission, so a resend would only route the user to the +;; exception page. Proves: permission loss is treated as permanent, like +;; every other terminal failure. +(t/deftest ^:async permission-loss-is-released-not-resumed (await (with-persistence - (^:async fn [{:keys [file-id response requests store]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist - (local-commit file-id) ::dps/force-persist - #(assoc-in % [:permissions :can-edit] false)) - (rx/push! response {:revn 1}) + (^:async fn [{:keys [file-id requests store]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) (= 1 (count (get-in @store [:persistence :queue])))) "permission loss fails the save")) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) - (ptk/emit! store - #(assoc-in % [:permissions :can-edit] true) + ;; Permission comes back and a file is opened again. + (ptk/emit! store #(assoc-in % [:permissions :can-edit] true) (ptk/data-event :app.main.data.common/change-team-role)) - (await (async/wait-for #(= 2 (count @requests)) "restoration resends")) - (t/is (= 2 (count @requests))) - (rx/push! response {:revn 2}) (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) (empty? (get-in @store [:persistence :queue]))) - "resumed save lands")) - (t/is (= :saved (get-in @store [:persistence :status]))) - (t/is (empty? (get-in @store [:persistence :queue]))))))) + "recovery releases the undeliverable edit")) + (t/is (empty? @requests) + "the undeliverable edit is never replayed, not even once the + permission is back") + (t/is (not= :error (get-in @store [:persistence :status])) + "the editor is not left halted"))))) + +;; Scenario: a terminal failure is waiting, and persistence is re-initialized. +;; Initialization must not lift the halt: only opening a file does. Otherwise +;; the edits would be released the moment the workspace view mounts, instead +;; of when the user acts on them. Proves: the init/recovery split holds. +(t/deftest ^:async initialization-does-not-lift-a-halt + (await + (with-persistence + (^:async fn [{:keys [file-id response store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (.error response (ex-info "Validation failed" {:type :validation})) + (await (async/wait-for #(and (= :error (get-in @store [:persistence :status])) + (= 1 (count (get-in @store [:persistence :queue])))) + "the save fails terminally")) + (ptk/emit! store (dps/initialize-persistence)) + (await (async/settle)) + (t/is (= :error (get-in @store [:persistence :status])) + "initialization leaves the halt alone") + (t/is (= 1 (count (get-in @store [:persistence :queue]))) + "and keeps the undeliverable edits queued until the user acts"))))) ;; Scenario: the answer arrives but no runner is tracking the request ;; anymore. The acknowledged commit stays queued instead of being dropped; @@ -740,27 +882,26 @@ (t/is (empty? (get-in @store [:persistence :queue]))))))) ;; Scenario: a queued commit carries a request id but persistence has no -;; run id for it when initializing — its outcome is unknowable. It errors -;; as `:save-outcome-unknown`, stays queued, and nothing is sent. -;; Proves: unknown outcomes report without replaying (never assume persisted, -;; never resend blindly). -(t/deftest ^:async recovery-reports-an-unknown-request-outcome-without-replaying-it +;; run id for it when initializing, so its outcome is unknown. It is sent +;; again under the same commit id and saves. Proves: an unknown outcome is +;; resent, since changes are idempotent. +(t/deftest ^:async recovery-sends-again-a-save-with-an-unknown-outcome (await (with-persistence - (^:async fn [{:keys [file-id requests store]}] + (^:async fn [{:keys [file-id response requests store]}] (let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next)) id (:id commit)] (ptk/emit! store #(assoc % :persistence {:queue (conj #queue [] id) :index {id commit} :status :saving}) (dps/initialize-persistence)) - (await (async/wait-for #(= :save-outcome-unknown - (get-in @store [:persistence :error :code])) - "unknown outcome errors")) - (t/is (= :error (get-in @store [:persistence :status]))) - (t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code]))) - (t/is (= [id] (vec (get-in @store [:persistence :queue])))) - (t/is (empty? @requests))))))) + (await (async/wait-for #(= 1 (count @requests)) "the commit is sent again")) + (t/is (= id (:commit-id (second (first @requests))))) + (rx/push! response {:revn 1}) + (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) + (empty? (get-in @store [:persistence :queue]))) + "the resent commit saves")) + (t/is (= 1 (count @requests)))))))) ;; Scenario: an edit is queued before persistence even initializes. On ;; initialization plus force-persist it is sent exactly once (still queued @@ -842,15 +983,156 @@ "the second save turns the guard off")) (t/is (empty? (get-in @store [:persistence :queue]))))))) -;; Scenario: the save fails terminally. The edit stays queued, and so does -;; the guard. Proves: retained edits are not dropped silently on leave. -(t/deftest ^:async failed-save-keeps-the-leave-page-guard +;; Scenario: a save fails terminally, then recovery releases the edit. The +;; guard stays on while the failed edit is retained, and turns off once it +;; is released. Proves: a release that empties the queue also turns the +;; leave-page warning off. +(t/deftest ^:async releasing-a-failed-edit-turns-the-leave-page-guard-off (await (with-persistence (^:async fn [{:keys [file-id store tasks]}] - (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) (await (async/wait-for #(= :error (get-in @store [:persistence :status])) - "the save fails")) - (t/is (= 1 (count (get-in @store [:persistence :queue])))) - (t/is (contains? @tasks :persistence) "the guard stays on for retained edits")) - (fn [_ _] (rx/throw (ex-info "invalid" {:type :validation})))))) + "the save fails terminally")) + (await (async/settle)) + (t/is (contains? @tasks :persistence) "the retained edit keeps the guard on") + (ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role)) + (await (async/wait-for #(empty? (get-in @store [:persistence :queue])) + "recovery releases the edit")) + (await (async/wait-for #(not (contains? @tasks :persistence)) + "the release turns the guard off")) + (t/is (= :saved (get-in @store [:persistence :status]))))))) + +;; Scenario: an edit waits in the 3 s buffer and the user switches tabs or +;; windows. Proves: leaving sends the buffered edit at once, well before +;; the buffer would (the bounded `wait-for` gives up long before 3 s). +(t/deftest ^:async leaving-the-tab-sends-buffered-edits-at-once + (let [leave-s (rx/subject)] + (await + (mock/with-mocks* + {dps/leave-signals (fn [] leave-s)} + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (ptk/emit! store (local-commit file-id)) + (await (async/settle)) + (t/is (empty? @requests) "the edit waits in the buffer") + (rx/push! leave-s :hidden) + (await (async/wait-for #(= 1 (count @requests)) + "leaving sends the edit at once")) + (t/is (= 1 (count @requests)))))))))) + +;; Scenario: nothing is buffered when the user leaves. Proves: a leave +;; signal alone sends nothing. +(t/deftest ^:async leaving-with-nothing-buffered-sends-nothing + (let [leave-s (rx/subject)] + (await + (mock/with-mocks* + {dps/leave-signals (fn [] leave-s)} + (await + (with-persistence + (^:async fn [{:keys [requests store]}] + (rx/push! leave-s :hidden) + (await (async/settle)) + (t/is (empty? @requests)) + (t/is (nil? (get-in @store [:persistence :status])))))))))) + +(defn- wait-files-saved + "Emits into `store` an event that runs `force-persist-files-and-wait` for + `file-ids` and then flips `done`." + [store file-ids timeout-ms done] + (ptk/emit! store + (ptk/reify ::wait-files-saved + ptk/WatchEvent + (watch [_ _ _] + (rx/concat + (dps/force-persist-files-and-wait file-ids timeout-ms) + (rx/of (ptk/reify ::files-saved + ptk/EffectEvent + (effect [_ _ _] (reset! done true))))))))) + +;; Scenario: an edit waits in the buffer when an export asks for its file +;; to be saved. Proves: the wait sends the edit and only ends once the save +;; lands. +(t/deftest ^:async waiting-for-a-file-sends-and-waits-for-its-edits + (let [done (atom false)] + (await + (with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (await + (mock/with-mocks* + {refs/persistence (l/derived :persistence store)} + (ptk/emit! store (local-commit file-id)) + (wait-files-saved store #{file-id} 60000 done) + (await (async/wait-for #(= 1 (count @requests)) "the edit is sent at once")) + (await (async/settle)) + (t/is (false? @done) "the wait holds while the save is in flight") + (rx/push! response {:revn 1}) + (await (async/wait-for #(true? @done) "the landed save ends the wait")) + (t/is (not (dps/unsaved-changes? @store #{file-id})))))))))) + +;; Scenario: a save of one file hangs while another file is exported. +;; Proves: the wait only covers the exported files. +(t/deftest ^:async waiting-for-a-file-ignores-other-files + (let [done (atom false)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {refs/persistence (l/derived :persistence store)} + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "the other file's save hangs")) + (wait-files-saved store #{(uuid/next)} 60000 done) + (await (async/wait-for #(true? @done) "the wait ends at once"))))))))) + +;; Scenario: the file's save hangs past the wait. Proves: the wait gives up +;; without raising, and the file still reports unsaved changes. +(t/deftest ^:async waiting-for-a-file-gives-up-after-the-timeout + (let [done (atom false)] + (await + (with-persistence + (^:async fn [{:keys [file-id requests store]}] + (await + (mock/with-mocks* + {refs/persistence (l/derived :persistence store)} + (ptk/emit! store (local-commit file-id)) + (wait-files-saved store #{file-id} 1 done) + (await (async/wait-for #(true? @done) "the wait times out")) + (t/is (= 1 (count @requests))) + (t/is (dps/unsaved-changes? @store #{file-id}))))))))) + +(defn- save-failed-events + "Starts collecting the `save-failed` audit events that `store` processes." + [store] + (let [events (atom [])] + (->> (ptk/input-stream store) + (rx/filter #(= ::ev/event (ptk/type %))) + (rx/map deref) + (rx/filter #(= "save-failed" (::ev/name %))) + (rx/subs! #(swap! events conj %))) + events)) + +;; Scenario: the backend rejects a save as invalid data. Proves: the user's +;; failure toast comes with one `save-failed` audit event that records why. +(t/deftest ^:async a-failed-save-emits-one-save-failed-event + (await + (with-persistence + (^:async fn [{:keys [file-id store]}] + (let [events (save-failed-events store)] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (await (async/wait-for #(= :error (get-in @store [:persistence :status])) + "the save fails")) + (await (async/wait-for #(seq @events) "the event is emitted")) + (await (async/settle)) + (t/is (= 1 (count @events))) + (t/is (= {:cause-type :validation + :code :referential-integrity + :retrying false + :queued-commits 1} + (select-keys (first @events) + [:cause-type :code :retrying :queued-commits]))))) + (fn [_ _] + (rx/throw (ex-info "invalid" {:type :validation :code :referential-integrity})))))) diff --git a/frontend/test/frontend_tests/data/repo_test.cljs b/frontend/test/frontend_tests/data/repo_test.cljs index 13066fbe9e..5334f1b719 100644 --- a/frontend/test/frontend_tests/data/repo_test.cljs +++ b/frontend/test/frontend_tests/data/repo_test.cljs @@ -8,7 +8,8 @@ (:require [app.main.repo :as repo] [beicon.v2.core :as rx] - [cljs.test :as t :include-macros true])) + [cljs.test :as t :include-macros true] + [cuerdas.core :as str])) ;; --------------------------------------------------------------------------- ;; retryable-error? tests (synchronous) @@ -29,6 +30,11 @@ (let [err (ex-info "service unavailable" {:type :service-unavailable})] (t/is (true? (repo/retryable-error? err)))))) +(t/deftest retryable-error-gateway-error + (t/testing "504 and the Cloudflare 52x family are retryable" + (let [err (ex-info "http error" {:type :gateway-error})] + (t/is (true? (repo/retryable-error? err)))))) + (t/deftest retryable-error-offline (t/testing "offline (status 0) is retryable" (let [err (ex-info "offline" {:type :offline})] @@ -215,3 +221,123 @@ (t/is (= 2 @call-count)) (t/is (= :authentication (:type (ex-data err)))) (done)))))))) + +;; --------------------------------------------------------------------------- +;; handle-response classification tests +;; --------------------------------------------------------------------------- + +(defn- capture-error + "Subscribes to `handle-response` for `response` and calls `k` with the + `ex-data` of the raised error." + [response k] + (->> (repo/handle-response response) + (rx/subs! + (fn [_] (t/is false "expected an error response") (k nil)) + (fn [cause] (k (ex-data cause)))))) + +(def ^:private html-error-page + "Web Page Blockedblocked") + +(t/deftest handle-response-gateway-statuses + (t/testing "every edge status that never reached the backend is a gateway error" + (t/async done + (let [statuses [504 520 521 522 523 524] + pending (atom (count statuses))] + (doseq [status statuses] + (capture-error {:status status + :body html-error-page + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :gateway-error (:type data)) + (str "status " status " should be a gateway error")) + (t/is (= status (:status data)) + (str "status " status " should be carried through")) + (when (zero? (swap! pending dec)) + (done))))))))) + +(t/deftest handle-response-rate-limit + (t/testing "a 429 with no interpretable body is a rate-limit error" + (t/async done + (capture-error {:status 429 + :body "" + :headers {} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :rate-limit (:type data))) + (t/is (= 429 (:status data))) + (done)))))) + +(t/deftest handle-response-intercepted-by-proxy + (t/testing "an error page from a filtering proxy is not an internal error" + (t/async done + (capture-error {:status 403 + :body html-error-page + :headers {"content-type" "text/html" + "server" "Zscaler/6.2"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= :unexpected-response (:type data))) + (t/is (= 403 (:status data))) + (done)))))) + +(t/deftest handle-response-keeps-body-excerpt + (t/testing "a body too long to keep is cut short and marked as cut" + (t/async done + (capture-error {:status 403 + :body (apply str (repeat 5000 "x")) + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= 503 (count (:data data))) "500 characters and an ellipsis") + (t/is (str/ends-with? (:data data) "...")) + (done)))))) + +(t/deftest handle-response-keeps-a-short-body-whole + (t/testing "a body short enough to read is passed through untouched" + (t/async done + (capture-error {:status 403 + :body html-error-page + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= html-error-page (:data data))) + (done)))))) + +(t/deftest handle-response-challenge-still-authorization + (t/testing "a Cloudflare challenge keeps its own type" + (t/async done + (capture-error {:status 403 + :body html-error-page + :headers {"server" "cloudflare" + "cf-mitigated" "challenge"} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :authorization (:type data))) + (t/is (= :challenge-required (:code data))) + (done)))))) + +(t/deftest handle-response-keeps-backend-error + (t/testing "an error the backend itself reported is passed through untouched" + (t/async done + (capture-error {:status 400 + :body {:type :validation :code :missing-param} + :headers {"content-type" "application/transit+json"} + :uri "https://design.penpot.app/api/main/methods/update-file"} + (fn [data] + (t/is (= :validation (:type data))) + (t/is (= :missing-param (:code data))) + (t/is (= 400 (:status data))) + (done)))))) + +(t/deftest handle-response-undecodable-non-error-is-internal + (t/testing "a non-error response we cannot interpret is still an internal error" + (t/async done + (capture-error {:status 301 + :body html-error-page + :headers {"content-type" "text/html"} + :uri "https://design.penpot.app/api/main/methods/get-teams"} + (fn [data] + (t/is (= :internal (:type data))) + (t/is (= :unable-to-process-repository-response (:code data))) + (done)))))) 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/errors_governor_test.cljs b/frontend/test/frontend_tests/errors_governor_test.cljs index c0998a7280..a51f775a43 100644 --- a/frontend/test/frontend_tests/errors_governor_test.cljs +++ b/frontend/test/frontend_tests/errors_governor_test.cljs @@ -2,7 +2,7 @@ ;; 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 +;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns frontend-tests.errors-governor-test "Unit tests for the error report governor (`app.main.errors`). diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index 11ba4d9573..dad9adf043 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -6,13 +6,18 @@ (ns frontend-tests.fonts-test (:require + [app.common.uuid :as uuid] + [app.main.data.fonts :as df] [app.main.fonts :as fonts] + [app.main.store :as st] [app.util.globals :as globals] [app.util.http :as http] + [app.util.webapi :as wa] [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])) (def sample-font {:id "sourcesanspro" @@ -244,3 +249,105 @@ ;; Trailing settle: the body is synchronous, but `with-mocks*` ;; evaluates to a promise, so the body must settle one. (await (async/settle)))))) + +;; --- custom font upload ------------------------------------------------------ +;; +;; Regression tests for https://github.com/penpot/penpot/issues/12304: +;; uploading a font whose opentype.js names are missing (nil subfamily, +;; as seen with some Fontshare families) crashed `process-upload` with +;; `re-seq must match against a string` and left the user with no +;; feedback. Unknown signatures and unparseable files were silently +;; dropped as well. + +(defn- bytes->buffer + [bytes] + (.-buffer (js/Uint8Array. (clj->js bytes)))) + +(defn- fake-ot-font + [{:keys [family variant hhea os2]}] + (let [names {"preferredFamily" family + "fontFamily" family + "preferredSubfamily" variant + "fontSubfamily" variant}] + #js {:getEnglishName (fn [k] (get names k)) + :tables #js {:hhea hhea :os2 os2}})) + +(t/deftest parse-font-mtype-detects-font-signatures + (t/is (= "font/ttf" (#'df/parse-font-mtype (bytes->buffer [0 1 0 0])))) + (t/is (= "font/otf" (#'df/parse-font-mtype (bytes->buffer [79 84 84 79])))) + (t/is (= "font/woff" (#'df/parse-font-mtype (bytes->buffer [119 79 70 70])))) + (t/is (= "font/woff2" (#'df/parse-font-mtype (bytes->buffer [119 79 70 50])))) + (t/is (nil? (#'df/parse-font-mtype (bytes->buffer [1 2 3 4])))) + (t/is (nil? (#'df/parse-font-mtype nil))) + (t/is (nil? (#'df/parse-font-mtype (bytes->buffer [0 1]))))) + +(t/deftest prepare-font-defaults-missing-subfamily + (let [font (fake-ot-font {:family "Satoshi" + :variant nil + :hhea #js {:ascender 1000 :descender 300} + :os2 #js {:usWinAscent 1000 :usWinDescent 300 + :sTypoAscender 800 :sTypoDescender 200 + :fsSelection 0}}) + entry (#'df/prepare-font {:font font + :type "font/otf" + :name "Satoshi-Regular.otf" + :data (bytes->buffer [79 84 84 79 0 0 0 0])})] + (t/is (= "Satoshi" (:font-family entry))) + (t/is (= 400 (:font-weight entry))) + (t/is (= "normal" (:font-style entry))) + (t/is (false? (:height-warning? entry))))) + +(t/deftest prepare-font-tolerates-missing-tables + (let [font (fake-ot-font {:family "Satoshi" :variant "Regular" :hhea nil :os2 nil}) + entry (#'df/prepare-font {:font font + :type "font/ttf" + :name "Satoshi-Regular.ttf" + :data (bytes->buffer [0 1 0 0 0 0 0 0])})] + (t/is (= "Satoshi" (:font-family entry))) + (t/is (false? (:height-warning? entry))))) + +(t/deftest parse-font-returns-error-on-unparseable + (t/testing "read errors pass through" + (t/is (= {:error "x.ttf"} (#'df/parse-font {:error "x.ttf"})))) + (t/testing "woff2 keeps a nil font for filename-based metadata" + (let [result (#'df/parse-font {:data (bytes->buffer [119 79 70 50]) + :type "font/woff2" + :name "Satoshi-Bold.woff2"})] + (t/is (nil? (:font result))) + (t/is (nil? (:error result))))) + (t/testing "opentype.js failures become errors instead of silent drops" + (let [garbage (bytes->buffer (into [0 1 0 0] (repeat 96 0))) + result (#'df/parse-font {:data garbage :type "font/ttf" :name "bad.ttf"})] + (t/is (= "bad.ttf" (:error result)))))) + +(t/deftest ^:async process-upload-reports-unknown-signature + (let [team-id (uuid/next) + blob #js {:name "mystery.bin"} + emitted (atom []) + result (atom nil)] + (await (mock/with-mocks* + {wa/read-file-as-array-buffer (mock/stub (fn [_] (rx/of (bytes->buffer [1 2 3 4])))) + st/emit! (mock/stub (fn [& evts] (swap! emitted into evts) nil))} + (await (async/observe (df/process-upload [blob] team-id) + :on-next #(reset! result %))))) + (t/is (= {} @result)) + (t/is (= 1 (count @emitted))) + (t/is (= :app.main.data.notifications/show (ptk/type (first @emitted)))))) + +(t/deftest ^:async process-upload-woff2-succeeds-from-filename + (let [team-id (uuid/next) + blob #js {:name "Satoshi-Bold.woff2"} + emitted (atom []) + result (atom nil)] + (await (mock/with-mocks* + {wa/read-file-as-array-buffer (mock/stub (fn [_] (rx/of (bytes->buffer [119 79 70 50 0 0 0 0])))) + st/emit! (mock/stub (fn [& evts] (swap! emitted into evts) nil))} + (await (async/observe (df/process-upload [blob] team-id) + :on-next #(reset! result %))) + (await (async/settle)))) + (t/is (= 1 (count (vals @result)))) + (let [entry (first (vals @result))] + (t/is (= "Satoshi" (:font-family entry))) + (t/is (= 700 (:font-weight entry))) + (t/is (= "normal" (:font-style entry)))) + (t/is (empty? @emitted)))) diff --git a/frontend/test/frontend_tests/helpers/async.cljs b/frontend/test/frontend_tests/helpers/async.cljs index 36bbaed769..1e52a49c25 100644 --- a/frontend/test/frontend_tests/helpers/async.cljs +++ b/frontend/test/frontend_tests/helpers/async.cljs @@ -2,7 +2,7 @@ ;; 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 +;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns frontend-tests.helpers.async "Async-first plumbing for ClojureScript tests. diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index b55b8e24bd..c5a2d8eb49 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -29,7 +29,11 @@ Requires a done-chained context (usually `t/async`): a synchronous test completes before its deferred body runs. - Usage: `(with-mocks {ns/sym mock-fn, ...} test-fn done)`" + Usage: `(with-mocks {ns/sym mock-fn, ...} test-fn done)`. + + `with-mocks*` is the preferred form for new tests: it wraps the body in a + generated `^:async` fn and evaluates to a promise, so there is no `done` to + thread. See [[run-mocked]] for the restore-on-settle machinery." #?(:cljs (:require [beicon.v2.core :as rx] [cljs.test :as t])) @@ -150,6 +154,50 @@ (^:async fn [] ~@body) (fn [] ~@restore-exprs))))))) +#?(:clj + (defmacro with-mocks* + "Installs `mocks` via `set!`, then evaluates `body` inside a generated + `^:async` fn awaited via `run-mocked`: when the body settles, the + originals are restored — always. A rejection is reported as an `:error`. + + Evaluates to a promise resolving once the body settles and the mocks + are restored. `await` it, either directly or by returning it from an + `^:async` fn; nested scopes must be awaited too. + + Example: + + (t/deftest ^:async my-async-test + (await (mock/with-mocks* + {app.main.repo/cmd! mock/rpc-cmd-mock} + (await (some-async-flow)) + ...)))" + [mocks & body] + (let [entries (map identity mocks) + gen-pairs (mapv (fn [[qsym _mock]] + {:qsym qsym + :osym (gensym "orig-")}) + entries) + let-bindings (vec (mapcat + (fn [{:keys [qsym osym]}] + [osym qsym]) + gen-pairs)) + install-exprs (mapv + (fn [[_qsym mock-fn] {:keys [qsym]}] + `(set! ~qsym ~mock-fn)) + entries + gen-pairs) + restore-exprs (mapv + (fn [{:keys [qsym osym]}] + `(set! ~qsym ~osym)) + gen-pairs)] + `(do + (frontend-tests.helpers.mock/reset-state!) + (let ~let-bindings + ~@install-exprs + (frontend-tests.helpers.mock/run-mocked + (^:async fn [] ~@body) + (fn [] ~@restore-exprs))))))) + ;; ═══════════════════════════════════════════════════════════════ ;; Runtime (ClojureScript only) ;; ═══════════════════════════════════════════════════════════════ diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index 6ac59e6259..4a4b7784a0 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -14,13 +14,11 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logic.libraries :as cll] - [app.common.types.component :as ctk] - [app.common.types.container :as ctn] - [app.common.types.file :as ctf] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] + [app.main.data.workspace.clipboard :as dwc] [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.layout :as layout])) @@ -225,34 +223,7 @@ (if (= parent-frame-id uuid/zero) shape (let [frame (get objects parent-frame-id)] - (gsh/translate-to-frame shape frame)))) - - ;; When copying an instance that is nested inside another one, we need to - ;; advance the shape refs to one or more levels of remote mains. - (advance-copies [data] - (let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)] - (update data :objects - #(reduce (partial advance-copy file libraries page) - % - heads)))) - - (advance-copy [file libraries page objects shape] - (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape))) - (let [level-delta (if (nil? (ctk/get-swap-slot shape)) - (ctn/get-nesting-level-delta (:objects page) shape uuid/zero) - 0)] - (if (pos? level-delta) - (reduce (partial advance-shape file libraries page level-delta) - objects - (cfh/get-children-with-self objects (:id shape))) - objects)) - objects)) - - (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})] - (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (assoc-in [(:id shape) :shape-ref] new-shape-ref))))] + (gsh/translate-to-frame shape frame))))] (let [file-id (:id file) frame-id (cfh/common-parent-frame objects selected) @@ -273,4 +244,4 @@ (map (partial prepare-object objects frame-id)) (reduce collect-data initial) sort-selected - advance-copies)))) + (dwc/advance-copies file libraries page selected))))) diff --git a/frontend/test/frontend_tests/helpers/state.cljs b/frontend/test/frontend_tests/helpers/state.cljs index 894caa2783..82e4e3d32c 100644 --- a/frontend/test/frontend_tests/helpers/state.cljs +++ b/frontend/test/frontend_tests/helpers/state.cljs @@ -25,7 +25,7 @@ ;; profile renderer preference instead of the global feature flag alone. :profile {:props {:renderer :wasm}}}) -(defn- on-error +(defn- report-error [cause] (js/console.error "STORE ERROR" (.-stack cause)) (t/do-report {:type :error :message "Store error" :actual cause}) @@ -34,7 +34,7 @@ (defn setup-store ([file] (setup-store file nil)) - ([file {:keys [renderer libraries] :as _opts}] + ([file {:keys [renderer libraries on-error] :or {on-error report-error} :as _opts}] (let [state (-> initial-state (assoc :current-file-id (:id file) :current-page-id (cthf/current-page-id file) diff --git a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs index 476d549c5d..f93bd40d3b 100644 --- a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs +++ b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs @@ -5,6 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.comp-remove-swap-slots-test (:require + [app.common.files.validate :as cfv] [app.common.test-helpers.components :as cthc] [app.common.test-helpers.compositions :as ctho] [app.common.test-helpers.files :as cthf] @@ -870,3 +871,149 @@ (t/is (some? copied-blue1')) (t/is (nil? (ctk/get-swap-slot copied-blue1'))))))))) + +(t/deftest test-keep-inherited-swap-slot-copy-paste-nested-copy-to-root + (t/async + done + (let [;; ==== Setup + ;; {:frame-red} [:name frame-red] # [Component :red] + ;; {:frame-blue} [:name frame-blue] # [Component :blue] + ;; {:frame-icon} [:name frame-icon] # [Component :icon] + ;; :red-copy-icon [:name frame-red] @--> frame-red + ;; {:frame-title} [:name frame-title] # [Component :title] + ;; :icon-copy-title [:name frame-icon] @--> frame-icon + ;; :blue-copy-title [:name frame-blue, :swap-slot-label :red-copy-icon] @--> frame-blue + ;; :title-copy [:name frame-title] #--> frame-title + ;; :icon-copy-in-title-copy [:name frame-icon] @--> :icon-copy-title + ;; :blue-copy-in-title-copy [:name frame-blue] @--> :blue-copy-title + file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame-red :name "frame-red") + (cthc/make-component :red :frame-red) + (ctho/add-frame :frame-blue :name "frame-blue") + (cthc/make-component :blue :frame-blue) + (ctho/add-frame :frame-icon :name "frame-icon") + (cthc/make-component :icon :frame-icon) + (cthc/instantiate-component :red :red-copy-icon :parent-label :frame-icon) + (ctho/add-frame :frame-title :name "frame-title") + (cthc/make-component :title :frame-title) + (cthc/instantiate-component :icon :icon-copy-title :parent-label :frame-title + :children-labels [:red-copy-in-icon-copy-title]) + (cthc/component-swap :red-copy-in-icon-copy-title :blue :blue-copy-title) + (cthc/instantiate-component :title :title-copy + :children-labels [:icon-copy-in-title-copy + :blue-copy-in-title-copy])) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + icon (cths/get-shape file :icon-copy-in-title-copy) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id icon)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + icon' (cths/get-shape file' :icon-copy-in-title-copy) + blue' (cths/get-shape file' :blue-copy-in-title-copy) + copied-icon' (find-copied-shape icon' page' uuid/zero) + copied-blue' (find-copied-shape blue' page' (:id copied-icon')) + errors (try + (cfv/validate-file! file' {(:id file') file'}) + nil + (catch :default cause + (ex-data cause)))] + + ;; ==== Check + ;; the copied icon now refers to the icon main, where blue + ;; does not exist, so the copied blue must keep the swap slot + ;; inherited from :blue-copy-title + (t/is (some? copied-icon')) + (t/is (= (:shape-ref copied-icon') (cthi/id :frame-icon))) + (t/is (some? copied-blue')) + (t/is (= (ctk/get-swap-slot copied-blue') (cthi/id :red-copy-icon))) + + ;; the file must be valid after the paste + (t/is (nil? errors)))))))) + +(t/deftest test-keep-touched-copy-paste-nested-copy-to-root + (t/async + done + (let [;; ==== Setup + ;; Add a nested copy inside a copy of :green, and override its fill + ;; so that the copy has a touched attribute. + file (-> (setup-file) + (cthc/instantiate-component :green :green-copy-2 + :parent-label :frame-b2 + :children-labels [:red-copy-in-green-copy-2]) + (cths/update-shape :red-copy-in-green-copy-2 + :fills (cths/sample-fills-color :fill-color "#ff0000"))) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + red (cths/get-shape file :red-copy-in-green-copy-2) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id red)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + red' (cths/get-shape file' :red-copy-in-green-copy-2) + copied-red' (find-copied-shape red' page' uuid/zero)] + + ;; ==== Check + ;; the touched fill override must be preserved in the pasted copy + (t/is (some? copied-red')) + (t/is (contains? (:touched copied-red') :fill-group)) + (t/is (= (:touched red') (:touched copied-red'))))))))) + +(t/deftest test-remove-swap-slot-copy-paste-nested-swapped-copy-to-root + (t/async + done + (let [;; ==== Setup (sample file as is) + file (setup-file) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + blue (cths/get-shape file :blue-copy-in-green-copy) + features #{"components/v2"} + version 47 + + pdata (thp/simulate-copy-shape #{(:id blue)} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + blue' (cths/get-shape file' :blue-copy-in-green-copy) + copied-blue' (find-copied-shape blue' page' uuid/zero)] + + ;; ==== Check + ;; the copied blue has no swap-id + (t/is (some? copied-blue')) + (t/is (nil? (ctk/get-swap-slot copied-blue'))))))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index 15a73ec196..c15fb8c639 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -11,6 +11,10 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations + - events in flight – the full report lists the events the + store was processing when it caught the exception; a failing stream + pipeline lists the emitters of the value, not its own event + - on-update-loop – never throws into the scheduler's caller - flash schedules async report and toast – neither the report nor ntf/show is emitted synchronously - organization SSO recovery – expired SSO sessions go back to the provider @@ -131,6 +135,33 @@ [events] (filter #(= ::ev/event (ptk/type %)) events)) +(defn- ^:async report-names + "Drives `flash-persistence` with an `ex-data` map of `cause-data` and + returns the audit event names it emitted, in order. + + The report generator is stubbed and the scheduler runs its callback + inline, so the whole flash body completes before returning. The emitter + is the only observable." + [cause-data] + (let [events (atom []) + cause (ex-info "Save failed" (assoc cause-data :hint "Save failed"))] + ;; The governor is global and remembers fingerprints for two minutes, so + ;; a second identical report would be suppressed. Clear it per case. + (errors/reset-report-governor!) + (await + (mock/with-mocks* + {errors/generate-report (fn [_ & _] "generated error report") + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + ;; `schedule` is multi-arity, so the mock needs every arity the + ;; flash body may call it with. + tm/schedule (mock/stub (fn + ([] nil) + ([callback] (callback)) + ([_ms callback] (callback))))} + (errors/flash-persistence cause))) + (mapv #(-> % deref ::ev/name) (report-events @events)))) + ;; --------------------------------------------------------------------------- ;; Environment failures ;; @@ -141,7 +172,7 @@ (t/deftest environment-error-classification (t/testing "environment failures are recognised" (doseq [type [:network :offline :bad-gateway :service-unavailable - :nitrate-unavailable :nitrate-not-configured]] + :gateway-error :nitrate-unavailable :nitrate-not-configured]] (t/is (true? (errors/environment-error? (error-cause :type type))) (str "expected environment error: " type)))) (t/testing "application defects are not environment failures" @@ -187,12 +218,29 @@ ;; evaluates to a promise, so the body must settle one. (await (async/settle)))))) +(t/deftest ^:async generate-report-bounds-a-huge-exception-message + ;; A message can embed a whole payload (a serialized file) and reach + ;; megabytes. The report must keep a bounded head in both the context and + ;; the trace, so the audit event built from it can never bloat. + (await + (mock/with-mocks* + {st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (let [huge (apply str (repeat 200000 "z")) + report (errors/generate-report (js/Error. huge))] + (t/is (string? report)) + (t/is (< (count report) 50000)) + (t/is (not (str/includes? report (subs huge 0 1500)))) + ;; Trailing settle: the body is synchronous, but `with-mocks*` + ;; evaluates to a promise, so the body must settle one. + (await (async/settle)))))) + (t/deftest ^:async connectivity-handlers-report-governed-compact-audit-events ;; Scenario: a network failure and an offline status through the global ;; handler. Each is reported as a governed compact audit event plus its ;; connection toast. Proves: per type, report first and toast right after, ;; with no stack in the report and the dedicated toast message. - (doseq [type [:network :offline]] + (doseq [type [:network :offline :gateway-error]] (errors/reset-report-governor!) (let [events (atom []) cause (ex-info "http error" {:type type :hint "http error"})] @@ -368,6 +416,144 @@ ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) +;; --------------------------------------------------------------------------- +;; Events in flight +;; +;; on-error records the events the store is processing when it catches an +;; exception; the full report lists them, outermost first. +;; --------------------------------------------------------------------------- + +(def ^:private in-flight-cause (atom nil)) + +(defmethod ptk/handle-error ::test-in-flight + [err] + (reset! in-flight-cause (::errors/instance err)) + nil) + +(def ^:private in-flight-heading + "Events in flight when the error was caught (outermost first):") + +(defn- in-flight-failure + [] + (ex-info "in-flight failure" {:type ::test-in-flight :hint "in-flight failure"})) + +(defn- recursive-event + [n] + (ptk/reify ::test-in-flight-recursive + ptk/WatchEvent + (watch [_ _ _] + (if (pos? n) + (rx/of (recursive-event (dec n))) + (throw (in-flight-failure)))))) + +(t/deftest ^:async report-lists-the-events-in-flight-of-a-failing-event + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-outer + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/reify ::test-in-flight-inner + ptk/UpdateEvent + (update [_ _] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause) + outer (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-outer)") + inner (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-inner)")] + (t/is (str/includes? report in-flight-heading)) + (t/is (< -1 outer inner) "outer event listed before the failing one")))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async report-keeps-both-ends-of-a-long-in-flight-list + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; 25 nested events: 10 from each end are kept + (st/emit! (recursive-event 24)) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (str/includes? report "... 5 more ...")) + (t/is (= 20 (count (re-seq #"test-in-flight-recursive" report))))))) + (finally + (rx/dispose! store))))) + +(defn- in-flight-section + "The in-flight section of `report`, or nil when absent." + [report] + (let [start (.indexOf report in-flight-heading)] + (when (<= 0 start) + (subs report start (.indexOf report "Last events:" start))))) + +(t/deftest ^:async stream-pipeline-failure-lists-the-emitters-of-the-value + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; The listener's pipeline fails on the trigger that the emitter emits + (st/emit! + (ptk/reify ::test-in-flight-listener + ptk/WatchEvent + (watch [_ _ stream] + (->> stream + (rx/filter (ptk/type? ::test-in-flight-trigger)) + (rx/take 1) + (rx/map (fn [_] (throw (in-flight-failure))))))) + (ptk/reify ::test-in-flight-emitter + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::test-in-flight-trigger {}))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [section (in-flight-section (errors/generate-report @in-flight-cause))] + (t/is (str/includes? section "test-in-flight-emitter")) + (t/is (not (str/includes? section "test-in-flight-trigger"))) + (t/is (not (str/includes? section "test-in-flight-listener")))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async delayed-watch-failure-has-no-in-flight-section + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-delayed + ptk/WatchEvent + (watch [_ _ _] + (->> (rx/timer 0) + (rx/map (fn [_] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (not (str/includes? report in-flight-heading))) + (t/is (str/includes? report "Last events:"))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async update-loop-handler-never-throws + (await + (mock/with-mocks* + {ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))} + (t/is (nil? (errors/on-update-loop {:rounds 26})))))) + ;; --------------------------------------------------------------------------- ;; Expired organization SSO session ;; @@ -661,7 +847,67 @@ ((:callback download) nil) (t/is (= [["report" "text/plain" "blob:report"]] @downloads)) (doseq [callback @idle-callbacks] (callback)) - (t/is (= ["blob:report"] @revoked))))))) + (t/is (= ["blob:report"] @revoked)) + ;; Autosave is stopped for the whole session, so the toast also + ;; offers the only action that restarts it without the user having + ;; to know they could leave the file and come back. + (let [reload (get-in state [:notification :links 1])] + (t/is (= "labels.reload-page:" (:label reload)) + "the `tr` stub renders a one-argument key with a trailing colon") + (t/is (fn? (:callback reload)) "the reload link has a callback"))))))) + +;; Scenario: a save fails terminally. The toast offers the report download +;; and a reload. Proves: the recovery action reaches the user. +;; +;; The reload callback itself is not invoked here: `js/location` does not +;; exist under the test runner, so the link is verified by its label and by +;; being a callable, not by calling it. +(t/deftest ^:async save-failure-toast-offers-a-reload-action + (let [events (atom [])] + (await + (mock/with-mocks* + {errors/generate-report (fn [_ & _] "generated error report") + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + tm/schedule (mock/stub (fn [callback] (callback)))} + (errors/flash-persistence (ex-info "Save failed" + {:type :validation :hint "Save failed"})) + (await (async/settle)) + ;; The report is emitted first and the toast right after, so the toast + ;; is the last event collected. + (let [state (ptk/update (last @events) {}) + links (get-in state [:notification :links])] + ;; `tr` is not stubbed here, and the test runner does not load the + ;; catalogue, so a key comes back verbatim without its arguments. + (t/is (= ["labels.download" "labels.reload-page"] (mapv :label links)) + "the toast carries the report and a reload, download first") + (t/is (every? #(fn? (:callback %)) links) + "both actions are callable") + (t/is (nil? (get-in state [:notification :timeout])) + "the toast stays until the user acts on it")))))) + +;; Scenario: a save fails on the transport and the queue keeps trying on +;; the slow cycle. The toast says the changes are kept and offers no reload, +;; which would lose them. Proves: the retrying variant tells the truth. +(t/deftest ^:async retrying-save-failure-toast-offers-no-reload + (let [events (atom [])] + (await + (mock/with-mocks* + {errors/generate-report (fn [_ & _] "generated error report") + st/emit! (mock/stub (fn [& emitted] + (swap! events into emitted))) + tm/schedule (mock/stub (fn [callback] (callback)))} + (errors/flash-persistence (ex-info "Save failed" + {:type :gateway-error :hint "Save failed"}) + :retrying true) + (await (async/settle)) + (let [state (ptk/update (last @events) {})] + (t/is (= "errors.save-still-retrying" (get-in state [:notification :content]))) + (t/is (= ["labels.download"] (mapv :label (get-in state [:notification :links]))) + "the toast carries the report and no reload") + (t/is (= errors/persistence-failed-tag (get-in state [:notification :tag])) + "a save that lands can hide it") + (t/is (nil? (get-in state [:notification :timeout])))))))) (t/deftest ^:async persistence-waiters-do-not-report-an-already-handled-failure (let [reports (atom []) @@ -764,28 +1010,89 @@ (t/is (= [::dw/reload-current-file] (mapv ptk/type @events))) (t/is (empty? @scheduled) "A restored version shows no save notification"))))) -(t/deftest ^:async other-validation-failures-during-save-keep-the-notification - (t/testing "a validation failure without a recovery of its own is still notified" - (let [events (atom []) - scheduled (atom [])] +(t/deftest ^:async save-failure-report-type-follows-the-cause + ;; The user still gets the same sticky toast and report link; only the audit + ;; event name changes, and that is what decides whether the incident reaches + ;; the internal reports and the alerts. + (t/testing "a validation failure is a defect, so it reports unhandled" + (t/is (= ["unhandled-exception"] + (await (report-names {:type :validation :code :invalid-data}))))) + + (t/testing "the wrapped shape reads the cause, not the wrapper" + ;; This is the shape that actually reaches flash-persistence: + ;; persistence-failed sets :type :persistence and keeps the real type + ;; under :cause-type. Reading :type alone would match the wrapper and + ;; leave the cause as handled. + (t/is (= ["unhandled-exception"] + (await (report-names {:type :persistence + :cause-type :validation + :code :invalid-data}))))) + + (t/testing "environment failures stay handled after the retry budget" + ;; They land here once the backoff is spent, and the user's connection is + ;; not our bug, so they must not alert. + (doseq [cause-type [:network :offline :bad-gateway :service-unavailable + :gateway-error]] + (t/is (= ["handled-exception"] + (await (report-names {:type :persistence + :cause-type cause-type}))) + (str cause-type " is an environment failure, not a defect")))) + + (t/testing "responses the backend never wrote stay handled" + (doseq [cause-type [:rate-limit :unexpected-response]] + (t/is (= ["handled-exception"] + (await (report-names {:type :persistence + :cause-type cause-type}))) + (str cause-type " comes from a proxy, not from our code")))) + + (t/testing "an unclassified failure keeps the handled path" + (t/is (= ["handled-exception"] + (await (report-names {:type :persistence + :cause-type :unknown-thing}))))) + + (t/testing "a bare :type still classifies, without a wrapper" + (t/is (= ["unhandled-exception"] + (await (report-names {:type :validation}))))) + + (t/testing "a server-side or internal defect reports unhandled" + (doseq [cause-type [:server-error :internal]] + (t/is (= ["unhandled-exception"] + (await (report-names {:type :persistence + :cause-type cause-type}))) + (str cause-type " is our bug"))))) + +;; --------------------------------------------------------------------------- +;; transport errors +;; --------------------------------------------------------------------------- + +(t/deftest ^:async transport-errors-are-handled-without-replacing-the-page + ;; Scenario: a rate limit and an error page the backend did not write, + ;; through the global handler. Proves: neither replaces the page, each is + ;; a handled report followed by one toast. + (doseq [type [:rate-limit :unexpected-response]] + (errors/reset-report-governor!) + (let [events (atom []) + assigned (atom []) + cause (ex-info "http error" + {:type type + :hint "http error" + :status 429 + :uri "https://design.penpot.app/api/main/methods/get-teams"})] (await (mock/with-mocks* - {errors/generate-report (fn [_ & _] "generated error report") - errors/submit-report (fn [& _]) - st/emit! (mock/stub (fn [& emitted] - (swap! events into emitted))) - tm/schedule (mock/stub (fn [callback] - (swap! scheduled conj callback)))} - (errors/flash-persistence (ex-info "Invalid data" {:type :validation - :code :invalid-data})) - (doseq [callback @scheduled] (callback)) + {st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace") + rt/assign-exception (fn [error] (swap! assigned conj error) error) + tm/schedule (mock/stub (fn [f] (f))) + st/format-last-events (mock/stub (fn [& _] "(stub last events)"))} + (errors/on-error cause) (await (async/settle)) - ;; The report is emitted first and the toast right after: one - ;; audit event plus the sticky toast carrying the report link. - (t/is (= 1 (count (report-events @events)))) - (let [state (ptk/update (last @events) {})] - (t/is (nil? (get-in state [:notification :timeout]))) - (t/is (some? (get-in state [:notification :links 0]))))))))) + (t/is (empty? @assigned) + (str type " must not replace the page with an error screen")) + (t/is (= ["handled-exception"] + (map (comp ::ev/name deref) (report-events @events))) + (str type " is reported as handled, not unhandled")) + (t/is (= 2 (count @events)) (str type " must show one notification"))))))) (t/deftest closed-branch-save-refusals-explain-themselves (t/testing "a save refused on a merged or archived branch warns without a report" diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 70d3e3fb7d..8edd8a0e30 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -6,6 +6,12 @@ (ns frontend-tests.plugins.library-test (:require + [app.common.files.changes-builder :as pcb] + [app.common.logic.shapes :as cls] + [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.types.component :as ctk] [app.common.uuid :as uuid] [app.main.data.event :as ev] @@ -14,8 +20,10 @@ [app.main.data.workspace.variants :as dwv] [app.main.repo :as rp] [app.main.store :as st] + [app.main.worker :as mw] [app.plugins.library :as library] [app.plugins.register :as r] + [app.plugins.shape :as shape] [app.plugins.text :as text] [app.plugins.utils :as u] [app.util.object :as obj] @@ -24,10 +32,163 @@ [cuerdas.core :as str] [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] + [frontend-tests.helpers.state :as ths] [potok.v2.core :as ptk])) (def ^:private plugin-id "00000000-0000-0000-0000-000000000000") +(defn- file-with-component + [] + (-> (cthf/sample-file :restore-file :page-label :main-page) + (cths/add-sample-shape :main :type :frame) + (cths/add-sample-shape :child :parent-label :main) + (cthc/make-component :restorable :main) + (cthc/instantiate-component :restorable :copy))) + +(defn- file-with-deleted-component + [] + (let [file (file-with-component) + page (cthf/current-page file) + changes (-> (pcb/empty-changes nil (:id page)) + (cls/generate-delete-shapes (:data file) page (:objects page) #{(thi/id :main)} {}) + second)] + (cthf/apply-changes file changes))) + +(t/deftest ^:async component-restore-preserves-the-users-view + (doseq [scenario [:same-page :other-page :linked-library]] + (let [file (cond-> (file-with-deleted-component) + (= scenario :other-page) (cthf/add-sample-page :other-page)) + store (if (= scenario :linked-library) + (ths/setup-store (cthf/sample-file :local-file) + {:renderer :svg :libraries [file]}) + (ths/setup-store file {:renderer :svg})) + view {:selected #{(thi/id :copy)} + :zoom 2 + :vport {:x 100 :y 200 :width 800 :height 600} + :vbox {:x 50 :y 100 :width 400 :height 300}} + view-of #(select-keys % [:current-file-id :current-page-id :workspace-local])] + (ptk/emit! store #(assoc % :workspace-local view)) + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (let [before (view-of @store)] + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& events] (apply ptk/emit! store events))) + mw/ask! (mock/stub (fn [_] (rx/empty))) + shape/lib-component-proxy library/lib-component-proxy} + (let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy)) + component (.component copy)] + (t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted]))) + (.restore component) + (await (async/wait-for #(not (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])) + "component restored")) + (.restore component) + (await (async/settle)) + (t/is (= (thi/id :main) + (get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :main) :id]))) + (t/is (= (thi/id :child) + (get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :child) :id]))) + (t/is (= before (view-of @store))) + (t/is (= [(str (thi/id :restorable))] + (mapv #(.-id %) (.-components (library/library-proxy plugin-id (:id file))))))))))))) + +(t/deftest ^:async component-restore-checks-library-write-permission + (let [file (file-with-deleted-component) + store (ths/setup-store file {:renderer :svg}) + events (atom [])] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + r/check-permission (mock/stub (constantly false))} + (let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))] + (t/is (thrown-with-msg? js/Error #"library:write" (.restore component))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])))))))) + +(t/deftest ^:async component-restore-rejects-a-missing-component + (let [file (cthf/sample-file :restore-file) + store (ths/setup-store file {:renderer :svg})] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store} + (let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))] + (t/is (thrown-with-msg? js/Error #"Component does not exist" (.restore component)))))))) + +(def ^:private component-mutations + [["remove" #(.remove %)] + ["duplicate" #(.duplicate %)] + ["annotation" #(set! (.-annotation %) "Usage")]]) + +(t/deftest ^:async component-mutations-check-library-write-permission + (let [file (file-with-component) + store (ths/setup-store file {:renderer :svg}) + before @store + events (atom [])] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + r/check-permission (mock/stub (constantly false))} + (let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))] + (doseq [[operation mutate] component-mutations] + (t/testing operation + (t/is (thrown-with-msg? js/Error #"library:write" (mutate component))))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (= (:files before) (:files @store)))))))) + +(t/deftest ^:async component-mutations-reject-linked-libraries + (let [library (file-with-component) + file (cthf/sample-file :consumer-file) + store (ths/setup-store file {:renderer :svg :libraries [library]}) + before @store + events (atom [])] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (let [component (library/lib-component-proxy plugin-id (:id library) (thi/id :restorable))] + (doseq [[operation mutate] component-mutations] + (t/testing operation + (t/is (thrown-with-msg? js/Error #"current file" (mutate component))))) + (await (async/settle)) + (t/is (empty? @events)) + (t/is (= (:files before) (:files @store)))))))) + +(t/deftest ^:async component-mutations-reject-missing-components + (let [file (cthf/sample-file :restore-file) + store (ths/setup-store file {:renderer :svg})] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store} + (let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))] + (doseq [[operation mutate] component-mutations] + (t/testing operation + (t/is (thrown-with-msg? js/Error #"Component does not exist" (mutate component)))))))))) + +(t/deftest ^:async update-main-checks-content-and-library-write-permissions + (doseq [denied ["content:write" "library:write"]] + (let [file (file-with-component) + store (ths/setup-store file {:renderer :svg}) + events (atom [])] + (ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true)) + (await + (mock/with-mocks* + {st/state store + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted))) + r/check-permission (mock/stub (fn [_ permission] (not= permission denied)))} + (let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))] + (t/is (thrown-with-msg? js/Error (re-pattern denied) (.updateMain copy))) + (await (async/settle)) + (t/is (empty? @events)))))))) + (t/deftest library-asset-proxies-expose-library-id (let [file-id (random-uuid) id (random-uuid)] @@ -239,19 +400,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 +480,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/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 56a3a2529d..a8e5033cef 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -18,17 +18,21 @@ [app.main.data.tokenscript :as ts] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.undo :as dwu] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.library :as library] [app.plugins.register :as r] [app.plugins.tokens :as ptok] [app.plugins.utils :as u] [app.util.object :as obj] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.async :as tha] + [clojure.datafy :refer [datafy]] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw] + [frontend-tests.tokens.helpers.state :as tohs] [potok.v2.core :as ptk])) (t/use-fixtures :each @@ -71,7 +75,7 @@ #js {:strokeColor "#000000" :strokeWidth 1}]) (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (obj/set! (aget (.-strokes rect) index) property value) - (await (tha/settle)) + (await (async/settle)) (t/is (= (apply dissoc tokens removed) (get-in @store path))) (t/is (= value (obj/get (aget (.-strokes rect) index) property)))))))) @@ -97,15 +101,344 @@ (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (let [^js gradient (obj/get (aget (.-strokes rect) 0) "strokeColorGradient")] (set! (.-startX gradient) 0.5) - (await (tha/settle)) + (await (async/settle)) (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) (t/is (= 0.5 (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "startX"))) (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) (obj/set! (aget (.-stops gradient) 0) "color" "#ff0000") - (await (tha/settle)) + (await (async/settle)) (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) (t/is (= "#ff0000" (obj/get (aget (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "stops") 0) "color")))))) +(defn- token-source-file + [file-label] + (let [set-id (uuid/next)] + (ctht/sample-file-with-tokens + :file-id file-label + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id set-id :name "Core")) + (ctob/add-theme (ctob/make-token-theme :name "Light" + :group "Mode" + :sets #{"Core"})) + (ctob/add-token set-id (ctob/make-token :name "color.primary" + :type :color + :value "#FF0000"))) + :status-fn #(ctos/set-tokens-status % #{} #{set-id})))) + +(defn- with-active-theme + "The tokens status of `file` with its first visible theme activated." + [file] + (let [tokens-lib (ctht/get-tokens-lib file) + theme-id (:id (first (remove ctob/hidden-theme? (ctob/get-themes tokens-lib))))] + (cfo/activate-theme (ctht/get-tokens-status file) tokens-lib theme-id))) + +(defn- tokens-unchanged? + "True when the token attributes of `file` in the store are the original ones." + [store file] + (let [attrs [:tokens-lib :tokens-status :tokens-source]] + (= (select-keys (:data file) attrs) + (select-keys (get-in @store [:files (:id file) :data]) attrs)))) + +(defn- record-not-valid + "A `u/not-valid` stub that collects each call into `errors`." + [errors] + (mock/stub (fn [plugin-id code value] (swap! errors conj [plugin-id code value])))) + +(t/deftest ^:async library-tokens-source-switches-to-connected-library + (let [file (token-source-file :local) + source (token-source-file :source) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (t/is (= (str (:id file)) (.. context -tokensSource -id))) + (set! (.-tokensSource context) source-proxy) + (await (async/wait-for #(= (:id source) (get-in @store [:files (:id file) :data :tokens-source])) + "library token source")) + (t/is (= (str (:id source)) (.. context -tokensSource -id))) + (t/is (= (datafy (ctht/get-tokens-status source)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (= (ctht/get-tokens-lib source) (u/locate-tokens-lib (:id file)))) + (t/is (false? (.. context -local -tokens -isEditableTokens))) + (t/is (= (ctht/get-tokens-lib file) + (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source)))))) + +(t/deftest ^:async library-tokens-source-restores-local-tokens + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (with-active-theme source))) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (set! (.-tokensSource context) (.-local context)) + (await (async/wait-for #(nil? (get-in @store [:files (:id file) :data :tokens-source])) + "local token source")) + (let [^js catalog (.. context -local -tokens)] + (t/is (= (str (:id file)) (.. context -tokensSource -id))) + (t/is (true? (.-isEditableTokens catalog))) + (t/is (= (ctht/get-tokens-lib file) (u/locate-tokens-lib (:id file)))) + (t/is (= #{} (ctos/get-active-set-ids (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (not-any? #(.-active ^js %) (.-themes catalog)))))))) + +(t/deftest ^:async library-tokens-source-supports-undo-and-redo + (let [file (token-source-file :local) + source (token-source-file :source) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (st/emit! (tohs/watch-undo-stack)) + (set! (.-tokensSource context) source-proxy) + (await (async/wait-for #(seq (get-in @store [:workspace-undo :items])) + "token source undo entry")) + (st/emit! dwu/undo) + (await (async/wait-for #(nil? (get-in @store [:files (:id file) :data :tokens-source])) + "undo token source")) + (t/is (= (datafy (ctht/get-tokens-status file)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (st/emit! dwu/redo) + (await (async/wait-for #(= (:id source) (get-in @store [:files (:id file) :data :tokens-source])) + "redo token source")) + (t/is (= (datafy (ctht/get-tokens-status source)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))))))) + +(t/deftest ^:async library-tokens-source-keeps-status-when-source-is-unchanged + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source))) + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext (str uuid/zero)) + source-proxy (library/library-proxy (str uuid/zero) (:id source))] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (set! (.-tokensSource context) source-proxy) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + +(t/deftest ^:async library-tokens-source-rejects-invalid-libraries + (let [file (token-source-file :local) + empty-lib (ctht/sample-file-with-tokens :file-id :empty) + indirect (-> (token-source-file :indirect) + (assoc-in [:data :tokens-source] (uuid/next))) + store (ths/setup-store file {:libraries [empty-lib indirect]}) + plugin-id (str uuid/zero) + ^js context (library/library-subcontext plugin-id) + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (doseq [[label value message] + [["not a library" "invalid" "invalid"] + ["not connected" (library/library-proxy plugin-id (uuid/next)) "Library is not connected"] + ["without own tokens" (library/library-proxy plugin-id (:id empty-lib)) "Library must have its own tokens"] + ["with another tokens source" (library/library-proxy plugin-id (:id indirect)) "Library must have its own tokens"]]] + (t/testing label + (reset! errors []) + (set! (.-tokensSource context) value) + (t/is (= [[plugin-id :tokensSource message]] @errors)))) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + +(t/deftest ^:async external-catalogs-reject-tokens-changes + (let [source (token-source-file :source) + other (token-source-file :other) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source))) + store (ths/setup-store file {:libraries [source other]}) + plugin-id (str uuid/zero)] + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (doseq [[catalog-label ^js catalog] + [["local catalog" (.. (library/library-subcontext plugin-id) -local -tokens)] + ["source catalog" (.. (library/library-subcontext plugin-id) -tokensSource -tokens)] + ["other connected catalog" (.-tokens (library/library-proxy plugin-id (:id other)))]]] + (t/testing catalog-label + (let [^js token-set (aget (.-sets catalog) 0) + ^js token (aget (.-tokens token-set) 0) + ^js theme (aget (.-themes catalog) 0)] + (t/is (false? (.-isEditableTokens catalog))) + (doseq [[label mutate] + [["create set" #(.addSet catalog #js {:name "New"})] + ["rename set" #(set! (.-name token-set) "Renamed")] + ["duplicate set" #(.duplicate token-set)] + ["remove set" #(.remove token-set)] + ["create token" #(.addToken token-set #js {:name "color.new" :type "color" :value "#00FF00"})] + ["rename token" #(set! (.-name token) "color.renamed")] + ["change token value" #(set! (.-value token) "#00FF00")] + ["change token description" #(set! (.-description token) "New description")] + ["duplicate token" #(.duplicate token)] + ["remove token" #(.remove token)] + ["create theme" #(.addTheme catalog #js {:name "Dark" :group "Mode"})] + ["rename theme" #(set! (.-name theme) "Dark")] + ["change theme group" #(set! (.-group theme) "Other")] + ["add set to theme" #(.addSet theme token-set)] + ["remove set from theme" #(.removeSet theme token-set)] + ["duplicate theme" #(.duplicate theme)] + ["remove theme" #(.remove theme)]]] + (t/testing label + (t/is (thrown-with-msg? js/Error #"Cannot modify tokens in an external library" + (mutate))) + (await (async/settle)) + (t/is (tokens-unchanged? store file)) + (t/is (tokens-unchanged? store source)) + (t/is (tokens-unchanged? store other))))))))))) + +(t/deftest ^:async external-catalogs-reject-status-changes + (let [source (token-source-file :source) + other (token-source-file :other) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (with-active-theme source))) + store (ths/setup-store file {:libraries [source other]}) + plugin-id (str uuid/zero) + message "Cannot change activation through a connected library; use penpot.library.local.tokens" + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (let [^js context (library/library-subcontext plugin-id)] + (t/is (true? (.-active ^js (aget (.. context -local -tokens -themes) 0)))) + (doseq [[catalog-label ^js catalog] + [["source catalog" (.. context -tokensSource -tokens)] + ["other connected catalog" (.-tokens (library/library-proxy plugin-id (:id other)))]]] + (t/testing catalog-label + (let [^js token-set (aget (.-sets catalog) 0) + ^js theme (aget (.-themes catalog) 0)] + (t/testing "reports the status of its own file" + (t/is (true? (.-active token-set))) + (t/is (false? (.-active theme)))) + (reset! errors []) + (set! (.-active token-set) false) + (.toggleActive token-set) + (set! (.-active theme) true) + (.toggleActive theme) + (await (async/settle)) + (t/is (= [[plugin-id :active message] + [plugin-id :toggleActive message] + [plugin-id :active message] + [plugin-id :toggleActive message]] + @errors)) + (t/is (tokens-unchanged? store file)) + (t/is (tokens-unchanged? store source)) + (t/is (tokens-unchanged? store other)))))))))) + +(t/deftest ^:async external-token-source-allows-set-activation + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source))) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js token-set (aget (.-sets catalog) 0)] + (t/is (true? (.-active token-set))) + (set! (.-active token-set) false) + (await (async/wait-for #(false? (.-active token-set)) "deactivate set")) + (.toggleActive token-set) + (await (async/wait-for #(true? (.-active token-set)) "toggle set")) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-allows-theme-activation + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source))) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js theme (aget (.-themes catalog) 0)] + (t/is (false? (.-active theme))) + (set! (.-active theme) true) + (await (async/wait-for #(true? (.-active theme)) "activate theme")) + (.toggleActive theme) + (await (async/wait-for #(false? (.-active theme)) "toggle theme")) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-theme-toggle-supports-undo + (let [source (token-source-file :source) + status (with-active-theme source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] status)) + store (ths/setup-store file {:libraries [source]})] + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js catalog (.. (library/library-subcontext (str uuid/zero)) -local -tokens) + ^js theme (aget (.-themes catalog) 0)] + (st/emit! (tohs/watch-undo-stack)) + (.toggleActive theme) + (await (async/wait-for #(seq (get-in @store [:workspace-undo :items])) + "theme toggle undo entry")) + (t/is (false? (.-active theme))) + (st/emit! dwu/undo) + (await (async/wait-for #(= (datafy status) + (datafy (get-in @store [:files (:id file) :data :tokens-status]))) + "undo external theme toggle")) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async external-token-source-allows-applying-tokens-to-shapes + (let [source (token-source-file :source) + file (-> (token-source-file :local) + (assoc-in [:data :tokens-source] (:id source)) + (assoc-in [:data :tokens-status] (ctht/get-tokens-status source)) + (ctho/add-rect :rect1) + (ctho/add-rect :rect2) + (ctho/add-rect :rect3)) + store (ths/setup-store file {:libraries [source]}) + page-id (cthf/current-page-id file)] + (swap! store assoc-in [:workspace-local :selected] #{(cthi/id :rect3)}) + (await + (mock/with-mocks* {st/state store st/stream (ptk/input-stream store)} + (let [^js context (api/create-context (str uuid/zero)) + ^js page (.-currentPage context) + ^js catalog (.. context -library -local -tokens) + ^js token-set (aget (.-sets catalog) 0) + ^js token (aget (.-tokens token-set) 0) + ^js rect1 (.getShapeById page (str (cthi/id :rect1))) + ^js rect2 (.getShapeById page (str (cthi/id :rect2)))] + (.applyToken rect1 token #js ["fill"]) + (.applyToShapes token #js [rect2] #js ["fill"]) + (.applyToSelected token #js ["fill"]) + (doseq [label [:rect1 :rect2 :rect3]] + (let [shape-path [:files (:id file) :data :pages-index page-id :objects (cthi/id label)]] + (await (async/wait-for #(= "color.primary" (get-in @store (conj shape-path :applied-tokens :fill))) + (str "apply external token to " label))) + (await (async/wait-for #(= "#ff0000" (get-in @store (conj shape-path :fills 0 :fill-color))) + (str "resolve external token on " label))))) + (t/is (= (ctht/get-tokens-lib file) (get-in @store [:files (:id file) :data :tokens-lib]))) + (t/is (= (datafy (ctht/get-tokens-status file)) + (datafy (get-in @store [:files (:id file) :data :tokens-status])))) + (t/is (tokens-unchanged? store source))))))) + +(t/deftest ^:async library-tokens-source-requires-content-write-permission + (let [file (token-source-file :local) + source (token-source-file :source) + plugin-id "read-only-plugin" + store (ths/setup-store file {:libraries [source]}) + ^js context (library/library-subcontext plugin-id) + source-proxy (library/library-proxy plugin-id (:id source)) + errors (atom [])] + (await + (mock/with-mocks* {st/state store + st/stream (ptk/input-stream store) + u/not-valid (record-not-valid errors)} + (set! (.-tokensSource context) source-proxy) + (t/is (= [[plugin-id :tokensSource "Plugin doesn't have 'content:write' permission"]] @errors)) + (await (async/settle)) + (t/is (tokens-unchanged? store file)))))) + ;; Regression coverage for issue #9162. ;; ;; Plugin code calling `shape.applyToken(token, ["fill"])` or @@ -212,6 +545,23 @@ (t/is (= #{:fill} (ptok/expand-token-attrs #{:fill}))) (t/is (= #{} (ptok/expand-token-attrs nil))))) +(t/deftest per-side-stroke-attrs?-distinguishes-sides-from-the-uniform-alias + (t/testing "the uniform alias is not per-side" + (t/is (false? (ptok/per-side-stroke-attrs? #{:stroke-width}))) + (t/is (false? (ptok/per-side-stroke-attrs? #{"stroke-width"})))) + + (t/testing "each explicit side is per-side, as keyword or string" + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-top}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-right}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-bottom}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{:stroke-width-left}))) + (t/is (true? (ptok/per-side-stroke-attrs? #{"stroke-width-left"})))) + + (t/testing "unrelated and empty attrs are not per-side" + (t/is (false? (ptok/per-side-stroke-attrs? #{:fill}))) + (t/is (false? (ptok/per-side-stroke-attrs? #{}))) + (t/is (false? (ptok/per-side-stroke-attrs? nil))))) + (t/deftest shape-apply-token-accepts-padding-top (t/async done @@ -313,6 +663,76 @@ (done))) 0)))) +;; Regression coverage for issue #12319. +;; +;; Per-side stroke widths are only supported on boards and rectangles, and the +;; `strokes` setter already rejects them elsewhere. The token path did not: +;; `shape.applyToken` / `token.applyToShapes` / `token.applyToSelected` bound a +;; per-side width token to an ellipse and left the stroke in the per-side state +;; the strokes setter refuses. The token path now rejects the four explicit side +;; properties on unsupported shapes while keeping the uniform `strokeWidth` +;; alias working. + +(t/deftest ^:async apply-token-rejects-per-side-stroke-width-on-unsupported-shape + (let [plugin-id "00000000-0000-0000-0000-000000000000" + set-id (cthi/new-id! :token-set) + token-id (cthi/new-id! :stroke-width-token) + theme-id (cthi/new-id! :theme) + file (-> (ctht/sample-file-with-tokens + :file-id :file1 + :lib-fn #(-> % + (ctob/add-set + (ctob/make-token-set :id set-id + :name "widths")) + (ctob/add-theme + (ctob/make-token-theme :id theme-id + :name "theme" + :sets #{"widths"})) + (ctob/add-token + set-id + (ctob/make-token :id token-id + :name "width.sm" + :type :stroke-width + :value 10))) + :status-fn #(ctos/set-tokens-status % #{theme-id} #{set-id}))) + store (ths/setup-store file) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js context (api/create-context plugin-id) + ^js ellipse (.createEllipse context) + id (obj/get ellipse "$id") + ^js library (.-library context) + ^js local (.-local library) + ^js catalog (.-tokens local) + ^js token-set (.getSetById catalog (str set-id)) + ^js token (.getTokenById token-set (str token-id)) + path [:files (:id file) :data :pages-index (cthf/current-page-id file) + :objects id :applied-tokens]] + + (swap! store assoc-in [:plugins :flags plugin-id :throw-validation-errors] true) + + (t/testing "shape.applyToken rejects an explicit per-side width" + (t/is (thrown? js/Error (.applyToken ellipse token #js ["strokeWidthTop"]))) + (t/is (nil? (get-in @store path)))) + + (t/testing "token.applyToShapes rejects an explicit per-side width" + (t/is (thrown? js/Error (.applyToShapes token #js [ellipse] #js ["strokeWidthLeft"]))) + (t/is (nil? (get-in @store path)))) + + (t/testing "token.applyToSelected rejects an explicit per-side width" + (swap! store assoc-in [:workspace-local :selected] #{id}) + (t/is (thrown? js/Error (.applyToSelected token #js ["strokeWidthRight"]))) + (t/is (nil? (get-in @store path)))) + + ;; The uniform alias stays valid: it expands to the four sides with the same + ;; token and resolves to a uniform width, which the strokes setter accepts. + (t/testing "the uniform strokeWidth alias is still accepted" + (.applyToken ellipse token #js ["strokeWidth"]) + (await (async/wait-for + #(= "width.sm" (get-in @store (conj path :stroke-width-top))) + "uniform stroke width token applied")) + (t/is (= "width.sm" (get-in @store (conj path :stroke-width-top))))))) + (t/deftest token-attr?-rejects-unknown-input (t/is (false? (boolean (ptok/token-attr? :not-a-real-attr)))) (t/is (false? (boolean (ptok/token-attr? "not-a-real-attr")))) @@ -525,6 +945,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/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..a52c1f5e2e 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,100 @@ (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-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])))) + (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-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)))))) + +(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-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-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}}]]})))))) + +(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-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/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 77678bb47c..7443d230f8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -17,6 +17,7 @@ [frontend-tests.data.fix-deleted-fonts-test] [frontend-tests.data.imports-test] [frontend-tests.data.jobs-test] + [frontend-tests.data.modal-test] [frontend-tests.data.nitrate-test] [frontend-tests.data.persistence-retry-test] [frontend-tests.data.persistence-test] @@ -109,6 +110,8 @@ [frontend-tests.tokens.referenced-token-value-test] [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.branch-review-common-test] [frontend-tests.ui.branches-compare-labels-test] @@ -118,6 +121,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] @@ -135,8 +139,9 @@ [frontend-tests.ui.settings-shortcuts-test] [frontend-tests.ui.shape-filters-test] [frontend-tests.ui.shortcuts-labels-test] - [frontend-tests.ui.shortcuts-recording-test] [frontend-tests.ui.sidebar-scroll-test] + [frontend-tests.ui.sortable-auto-scroll-test] + [frontend-tests.ui.static-test] [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.text-attrs-multiple-test] [frontend-tests.ui.workspace-libraries-test] @@ -149,8 +154,10 @@ [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] [goog.object :as gobj])) (enable-console-print!) @@ -173,6 +180,7 @@ 'frontend-tests.data.dashboard-test 'frontend-tests.data.event-test 'frontend-tests.data.fix-deleted-fonts-test + 'frontend-tests.data.modal-test 'frontend-tests.data.nitrate-test 'frontend-tests.data.persistence-retry-test 'frontend-tests.data.persistence-test @@ -183,6 +191,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 @@ -267,6 +276,8 @@ 'frontend-tests.tokens.referenced-token-value-test '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.branch-review-common-test @@ -277,6 +288,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 @@ -296,8 +308,9 @@ 'frontend-tests.ui.settings-shortcuts-test 'frontend-tests.ui.shape-filters-test 'frontend-tests.ui.shortcuts-labels-test - 'frontend-tests.ui.shortcuts-recording-test 'frontend-tests.ui.sidebar-scroll-test + 'frontend-tests.ui.sortable-auto-scroll-test + 'frontend-tests.ui.static-test 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.text-attrs-multiple-test 'frontend-tests.ui.workspace-libraries-test @@ -309,6 +322,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/tokens/logic/token_data_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs index ee2d94a31f..fe1a145ce8 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -15,10 +15,12 @@ [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.data.workspace.undo :as dwu] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] [frontend-tests.helpers.pages :as thp] [frontend-tests.helpers.state :as ths] [frontend-tests.tokens.helpers.state :as tohs] - [frontend-tests.tokens.helpers.tokens :as toht])) + [frontend-tests.tokens.helpers.tokens :as toht] + [potok.v2.core :as ptk])) (t/use-fixtures :each {:before thp/reset-idmap!}) @@ -895,3 +897,64 @@ (t/testing "Tokens lib has been imported into file without existing lib" (t/is (some? tokens-lib')) (t/is (some? (ctob/get-set-by-name tokens-lib' "Imported Set")))))))))) + +(defn- setup-tokens-source-library + [] + (ctho/sample-file-with-tokens + :file-id :library + :lib-fn #(-> % + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :source-set) + :name "Source")) + (ctob/add-token (cthi/id :source-set) + (ctob/make-token :id (cthi/new-id! :source-token) + :name "color.source" + :type :color + :value "#FF0000")) + (ctob/add-theme (ctob/make-token-theme :id (cthi/new-id! :source-theme) + :name "Light" + :group "Mode" + :sets #{"Source"}))) + :status-fn #(ctos/set-tokens-status % #{} #{(cthi/id :source-set)}))) + +(t/deftest ^:async external-tokens-source-rejects-tokens-lib-changes + (let [source (setup-tokens-source-library) + file (-> (setup-file-with-token-lib) + (assoc-in [:data :tokens-source] (:id source))) + errors (atom []) + store (ths/setup-store file {:libraries [source] + :on-error #(swap! errors conj %)}) + tokens-lib (ctho/get-tokens-lib source) + set-id (cthi/id :source-set) + token-id (cthi/id :source-token) + theme-id (cthi/id :source-theme)] + (doseq [[label event] + [["create theme" (dwtl/create-token-theme (ctob/make-token-theme :name "Dark" :group "Mode"))] + ["update theme" (dwtl/update-token-theme theme-id (ctob/rename (ctob/get-theme tokens-lib theme-id) "Dark"))] + ["delete theme" (dwtl/delete-token-theme theme-id)] + ["create set" (dwtl/create-token-set (ctob/make-token-set :name "New"))] + ["rename set" (dwtl/rename-token-set (ctob/get-set tokens-lib set-id) "Renamed")] + ["rename set group" (dwtl/rename-token-set-group ["Source"] "Renamed")] + ["duplicate set" (dwtl/duplicate-token-set set-id)] + ["delete set" (dwtl/delete-token-set set-id)] + ["delete set group" (dwtl/delete-token-set-group ["Source"])] + ["drop set group" (dwtl/drop-token-set-group {:from-index 0 :to-index 0 :position :top})] + ["drop set" (dwtl/drop-token-set {:from-index 0 :to-index 0 :position :top})] + ["import tokens lib" (dwtl/import-tokens-lib (ctob/make-tokens-lib))] + ["create token" (dwtl/create-token set-id (ctob/make-token :name "color.new" + :type :color + :value "#00FF00"))] + ["bulk create tokens" (dwtl/bulk-create-tokens set-id [token-id] :color {:depth 0} "brand")] + ["update token" (dwtl/update-token set-id token-id {:value "#00FF00"})] + ["bulk update tokens" (dwtl/bulk-update-tokens set-id [token-id] :color "color" "brand")] + ["delete token" (dwtl/delete-token set-id token-id)] + ["bulk delete tokens" (dwtl/bulk-delete-tokens set-id [token-id])] + ["duplicate token" (dwtl/duplicate-token token-id)]]] + (t/testing label + (reset! errors []) + (ptk/emit! store event) + (await (async/wait-for #(seq @errors) label)) + (t/is (= [{:type :assertion :code :tokens-not-editable}] + (map #(select-keys (ex-data %) [:type :code]) @errors))))) + (await (async/settle)) + (t/is (= file (get-in @store [:files (:id file)]))) + (t/is (= source (get-in @store [:files (:id source)]))))) diff --git a/frontend/test/frontend_tests/tokens/token_path_test.cljs b/frontend/test/frontend_tests/tokens/token_path_test.cljs new file mode 100644 index 0000000000..06033e0882 --- /dev/null +++ b/frontend/test/frontend_tests/tokens/token_path_test.cljs @@ -0,0 +1,39 @@ +;; 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-path-test + (:require + [app.main.ui.workspace.tokens.management :as wtm] + [cljs.test :as t :include-macros true])) + +;; https://github.com/penpot/penpot/issues/11588 +;; A token whose name equals a tree node path must stay in the operations of +;; that node, alongside the tokens nested below it. + +(t/deftest token-in-path-matches-the-path-itself + (t/testing "an ancestor token is in its own path" + (t/is (true? (wtm/token-in-path? "a.a" "a.a"))) + (t/is (true? (wtm/token-in-path? "a" "a"))))) + +(t/deftest token-in-path-matches-nested-tokens + (t/testing "a token nested below the path is in the path" + (t/is (true? (wtm/token-in-path? "a.a" "a.a.a"))) + (t/is (true? (wtm/token-in-path? "a" "a.b.c"))) + (t/is (true? (wtm/token-in-path? "a.a" "a.a.b.c"))))) + +(t/deftest token-in-path-rejects-non-descendants + (t/testing "a sibling is not in the path" + (t/is (false? (wtm/token-in-path? "a.a" "a.b")))) + + (t/testing "a deeper path does not match a shorter token" + (t/is (false? (wtm/token-in-path? "a.a.a" "a.a")))) + + (t/testing "a name sharing the prefix without a separator boundary is not in the path" + (t/is (false? (wtm/token-in-path? "a.a" "a.ab"))) + (t/is (false? (wtm/token-in-path? "a" "ab")))) + + (t/testing "an unrelated path is not in the path" + (t/is (false? (wtm/token-in-path? "x.y" "z"))))) 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/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 {} {})))))) diff --git a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs index 0f509d3a08..790b749aef 100644 --- a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs +++ b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs @@ -55,4 +55,9 @@ ;; force a re-render so the X/Y inputs enabled/disabled state updates. (t/is (false? (check-measures-menu-props (props (assoc base-values :layout-item-absolute false)) - (props (assoc base-values :layout-item-absolute true))))))) + (props (assoc base-values :layout-item-absolute true)))))) + + (t/testing "re-renders when clip content changes but nothing else does" + (t/is (false? (check-measures-menu-props + (props (assoc base-values :show-content false)) + (props (assoc base-values :show-content true))))))) diff --git a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs index 6cf00fa228..6d10a260f4 100644 --- a/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs +++ b/frontend/test/frontend_tests/ui/organization_team_switch_test.cljs @@ -46,6 +46,26 @@ (t/is (= ["org-a" "org-b" "org-c" nil] (map :id (dts/sort-organizations organizations)))))) +(t/deftest names-starting-with-an-accented-letter-sort-as-the-plain-letter + ;; A code-point comparison would push "Ábaco", "Órbita" and "Ámbar" + ;; after "z"; accented letters must sort like their base letter. + (let [organizations [{:id "org-1" :name "OrgA"} + nil + {:id "org-2" :name "Órbita"} + {:id "org-3" :name "beta"} + {:id "org-4" :name "Ábaco"}] + teams [{:id "t1" :name "zeta"} + {:id "t2" :name "Personal projects" :is-default true} + {:id "t3" :name "apricot"} + {:id "t4" :name "Ámbar"}] + display-name (fn [team] (if (:is-default team) "Personal projects" (:name team)))] + (t/is (= ["Ábaco" "beta" "Órbita" "OrgA" nil] + (map :name (dts/sort-organizations organizations)))) + (t/is (= ["Ámbar" "apricot" "zeta" "Personal projects"] + (map :name (dts/sort-organization-teams teams)))) + (t/is (= ["Ámbar" "apricot" "zeta" "Personal projects"] + (map :name (dts/sort-all-teams teams display-name)))))) + (t/deftest closed-control-line-2-matrix (t/testing "profile with organizations, destination has an organization" (t/is (= "Org A" diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index e9913c9b26..3ed7e035c9 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,7 +1,12 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require [app.config :as cf] + [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] + [app.main.data.shortcuts :as ds] + [app.main.data.viewer.shortcuts :as vsc] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.shortcuts :as wsc] [app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal] [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] @@ -13,21 +18,21 @@ (t/deftest validate-imported-shortcuts-accepts-valid-workspace (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape" + {:workspace {:draw-rect "shift+r" :increase-zoom "+" :zoom-lense-decrease "alt+z"}})))) (t/deftest validate-imported-shortcuts-accepts-optional-contexts (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape"} + {:workspace {:draw-rect "shift+r"} :dashboard {:toggle-theme "alt+m"} :viewer {:next-frame "right"}})))) (t/deftest validate-imported-shortcuts-accepts-empty-string-command (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape ""}})))) + {:workspace {:draw-rect ""}})))) (t/deftest validate-imported-shortcuts-rejects-nil (let [result (sut/validate-imported-shortcuts nil)] @@ -51,19 +56,19 @@ (t/is (some #(= "workspace" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-non-string-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape 123}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect 123}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-vector-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape ["ctrl+e"]}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect ["ctrl+e"]}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-unknown-top-level-key - (let [result (sut/validate-imported-shortcuts {:workspace {:escape "escape"} + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect "shift+r"} :unknown-context {}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) @@ -86,6 +91,195 @@ (t/is (= false (:valid? (sut/validate-imported-shortcuts #{:workspace})))) (t/is (= false (:valid? (sut/validate-imported-shortcuts (fn [])))))) +(t/deftest sanitize-imported-shortcuts-skips-unknown-action-keys + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["workspace/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-unknown-contexts + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["unknown-context"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r"} + :unknown-context {:draw-rect "shift+r"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-string-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect 123 + :increase-zoom ["+"] + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-invalid-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "foo+bar" + :increase-zoom "ctrl+" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest cancel-copy-and-paste-are-not-customizable + (t/testing "in the workspace and in the path editor, which share the + shortcut keys of the workspace context" + (doseq [shortcuts [wsc/shortcuts psc/shortcuts] + k [:escape :copy :paste]] + (t/is (false? (get-in shortcuts [k :customizable])) + (str k))))) + +(t/deftest sanitize-imported-shortcuts-skips-non-customizable-keys + (t/testing "cancel, copy and paste cannot be imported, even with a free + key combination" + (t/is (= {:shortcuts {} + :skipped ["workspace/copy" "workspace/escape" "workspace/paste"]} + (sut/sanitize-imported-shortcuts + {:workspace {:escape "shift+escape" + :copy "ctrl+shift+1" + :paste "ctrl+shift+2"}}))))) + +(t/deftest apply-custom-overrides-skips-non-customizable-shortcuts + (t/testing "an override stored before a shortcut became non-customizable + is ignored, since the settings page can no longer reset it" + (let [shortcuts {:escape {:command "escape" :customizable false} + :undo {:command "ctrl+z"}} + result (ds/apply-custom-overrides shortcuts + {:workspace {:escape "shift+escape" + :undo "ctrl+u"}} + :workspace)] + (t/is (= "escape" (get-in result [:escape :command]))) + (t/is (= "ctrl+u" (get-in result [:undo :command])))))) + +(t/deftest sanitize-imported-shortcuts-skips-reserved-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "escape" + :increase-zoom "ctrl+v" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-copy-combination + (t/is (= {:shortcuts {:workspace {:draw-rect "ctrl+alt+c"}} + :skipped ["workspace/draw-ellipse"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-ellipse "ctrl+c" + :draw-rect "ctrl+alt+c"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-map-context + (t/is (= {:shortcuts {:viewer {:next-frame "right"}} + :skipped ["workspace"]} + (sut/sanitize-imported-shortcuts + {:workspace "not-a-map" + :viewer {:next-frame "right"}})))) + +(t/deftest sanitize-imported-shortcuts-drops-context-without-viable-entries + (t/testing "a context whose entries are all skipped is left out, so the + import does not clear the user's shortcuts in that context" + (t/is (= {:shortcuts {} + :skipped ["viewer/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:viewer {:not-a-real-shortcut "x"}}))))) + +(t/deftest sanitize-imported-shortcuts-keeps-empty-context + (t/is (= {:shortcuts {:workspace {}} + :skipped []} + (sut/sanitize-imported-shortcuts {:workspace {}})))) + +(t/deftest sanitize-imported-shortcuts-returns-nil-for-non-maps + (t/is (nil? (sut/sanitize-imported-shortcuts nil))) + (t/is (nil? (sut/sanitize-imported-shortcuts "shortcuts"))) + (t/is (nil? (sut/sanitize-imported-shortcuts 42))) + (t/is (nil? (sut/sanitize-imported-shortcuts [])))) + +(t/deftest sanitize-imported-shortcuts-output-is-valid + (let [{:keys [shortcuts]} (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x" + :increase-zoom 1} + :dashboard "not-a-map" + :viewer {:next-frame "right"} + :unknown-context {}})] + (t/is (= {:valid? true} + (sut/validate-imported-shortcuts shortcuts))))) + +(t/deftest valid-command-accepts-key-combinations + (t/are [command] (true? (ui-shortcuts/valid-command? command)) + "" + "a" + "ctrl+shift+k" + "Ctrl+s" + "command+alt+ñ" + "+" + "shift++" + "ctrl+plus" + "shift+\"" + "g v" + "escape" + "Escape" + "f1" + "f12" + "f24" + "num5" + "alt")) + +(t/deftest valid-command-rejects-other-strings + (t/are [command] (false? (ui-shortcuts/valid-command? command)) + "foo" + "foo+bar" + "a+b" + "ctrl+foo" + "ctrl+" + "ctrl+shift" + "++" + "f25" + " " + " a" + "a " + "a b" + "🙂" + nil + 123)) + +(t/deftest reserved-command-matches-non-customizable-combinations + (t/testing "cancel, copy and paste keep their keys; spelling and modifier + order do not matter, and a reserved step taints a sequence" + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (true? (ui-shortcuts/reserved-command? command)) + "escape" + "Escape" + "esc" + "ctrl+c" + "Control+C" + "mod+c" + "ctrl+v" + "Control+V" + "mod+v" + "g escape" + "ctrl+c g")))) + +(t/deftest reserved-command-ignores-other-combinations + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (false? (ui-shortcuts/reserved-command? command)) + "" + "v" + "c" + "shift+escape" + "ctrl+shift+v" + "ctrl+shift+c" + "ctrl+alt+c" + "shift+alt+c" + nil))) + +(t/deftest valid-command-accepts-every-default-command + (t/testing "a profile exported by Penpot always holds valid commands" + (doseq [shortcuts [psc/shortcuts wsc/shortcuts dsc/shortcuts vsc/shortcuts] + [shortcut-key {:keys [command]}] shortcuts + command (if (vector? command) command [command]) + :when (some? command)] + (t/is (ui-shortcuts/valid-command? command) + (str shortcut-key " -> " (pr-str command)))))) + (t/deftest import-custom-shortcuts-updates-multiple-contexts (let [imported {:workspace {:add-comment "alt+c"} :viewer {:select-all "ctrl+alt+a"}} @@ -172,17 +366,17 @@ (t/is (= "ctrl+shift+a" (:imported select-all-entry)))))) (t/deftest compute-diff-shows-empty-string-as-disabled-shortcut - (let [imported {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} current {} diff (diff-modal/compute-diff imported current)] (t/is (pos? (count diff))) - (let [entry (first (filter #(= :escape (:key %)) diff))] + (let [entry (first (filter #(= :draw-rect (:key %)) diff))] (t/is (= :workspace (:context entry))) (t/is (= "" (:imported entry)))))) (t/deftest compute-diff-excludes-unchanged-shortcuts - (let [imported {:workspace {:escape ""}} - current {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} + current {:workspace {:draw-rect ""}} diff (diff-modal/compute-diff imported current)] (t/is (empty? diff)))) @@ -293,9 +487,8 @@ ;; --- keyboard-event->mousetrap across keyboard layouts ------------------- ;; `keyboard-event->mousetrap` (private, exercised via #') turns a recorded -;; keydown into a Mousetrap-style combo string, reading `event.code` -;; (layout-independent) rather than `event.key` (layout- and Shift-dependent) -;; so the recorded combo matches what Mousetrap resolves positionally. Real +;; keydown into a Mousetrap-style combo string from `event.key` (the typed +;; glyph), so recording and dispatch always use the same token. Real ;; keydown events are wrapped by goog.events in a goog.events.BrowserEvent, ;; which forwards `key` but not `code`; the fixtures below simulate that ;; wrapper via `getBrowserEvent`. @@ -326,35 +519,47 @@ :metaKey (boolean meta)}) (t/deftest mousetrap-shift-period-on-us-layout - (t/testing "US layout: Shift + Period key types \">\"" + (t/testing "US layout without a layout map: Shift + Period types \">\" and + records the glyph, the same token Mousetrap dispatches" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (t/is (= "shift+>" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ">" :code "Period" :shift true}))))))) -(t/deftest mousetrap-shift-period-on-spanish-iso-layout - (t/testing "Spanish (ISO) layout: Shift + Period key types \":\" instead of - \">\", but the physical key (code \"Period\") is the same as on - a US layout, so the recorded combo must match" +(t/deftest mousetrap-shift-period-with-layout-map-records-primary + (t/testing "with a layout map, Shift + Period records the primary \".\" + even when the typed glyph differs" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (try + (reset! ds/layout-map #js {:get (fn [code] (when (= code "Period") "."))}) + (t/is (= "shift+." + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key ">" :code "Period" :shift true})))) + (t/is (= "shift+." + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key ":" :code "Period" :shift true})))) + (finally + (reset! ds/layout-map nil)))))) + +(t/deftest mousetrap-shift-period-on-spanish-iso-layout + (t/testing "Spanish (ISO) layout without a layout map: Shift + Period + types \":\" and records the glyph" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+:" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key ":" :code "Period" :shift true}))))))) (t/deftest mousetrap-shift-period-on-arbitrary-layout - (t/testing "an arbitrary layout where Shift + Period produces a character - unrelated to \".\" or \":\" still normalizes to \"shift+.\", - since matching is driven by the physical key, not the glyph" + (t/testing "without a layout map the typed glyph wins over the physical + position: \"?\" on the Period position records \"shift+?\"" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (t/is (= "shift+?" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "?" :code "Period" :shift true}))))))) (t/deftest mousetrap-letter-key-on-azerty-layout (t/testing "French AZERTY: the key printed \"A\" sits where \"Q\" is on a US - layout (code \"KeyQ\") and types \"a\". Mousetrap doesn't - resolve letters positionally (absent from its - _MAP/_KEYCODE_MAP), so recording must stay glyph-based + layout (code \"KeyQ\") and types \"a\". Letters resolve by glyph (\"a\", not \"q\") to match what that physical key triggers" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "a" @@ -364,19 +569,17 @@ (t/deftest mousetrap-digit-key-on-azerty-layout (t/testing "French AZERTY: the unshifted top-row key at the US \"1\" position (code \"Digit1\") types \"&\". Digits, like letters, - are absent from Mousetrap's _MAP/_KEYCODE_MAP, so recording - must stay glyph-based (\"&\") rather than resolve positionally - to \"1\"" + resolve by glyph (\"&\") rather than positionally to \"1\"" (with-redefs [cf/check-platform? (constantly false)] (t/is (= "&" (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "&" :code "Digit1"}))))))) (t/deftest mousetrap-reads-code-from-unwrapped-native-event - (t/testing "falls back to reading `code` straight off the event when it is - not a goog.events.BrowserEvent wrapper (no getBrowserEvent)" + (t/testing "recording works on a plain native event too (glyph wins + without a layout map)" (with-redefs [cf/check-platform? (constantly false)] - (t/is (= "shift+." + (t/is (= "shift+:" (#'ui-shortcuts/keyboard-event->mousetrap (mock-native-keydown {:key ":" :code "Period" :shift true}))))))) @@ -417,3 +620,116 @@ (#'ui-shortcuts/keyboard-event->mousetrap (mock-wrapped-keydown {:key "q" :code "KeyQ" :ctrl true :alt true :shift true}))))))) + +(t/deftest mousetrap-shifted-letter-lowercases + (t/testing "Ctrl+Shift+Z types \"Z\" but records \"ctrl+shift+z\" so the + bind and the event use the same token" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "ctrl+shift+z" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Z" :code "KeyZ" + :ctrl true :shift true}))))))) + +(t/deftest mousetrap-shifted-digit-resolves-to-digit + (t/testing "a digit row key held with Shift records the digit itself: US + Shift+0 types \")\" but records \"shift+0\", so the physical + digit key means the same on every layout (production keyCode + behavior)" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+0" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key ")" :code "Digit0" :shift true})))) + (t/is (= "shift+1" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "!" :code "Digit1" :shift true})))) + (t/is (= "shift+2" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "@" :code "Digit2" :shift true})))) + (t/is (= "shift+8" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "*" :code "Digit8" :shift true}))))))) + +(t/deftest mousetrap-shifted-digit-already-digit-unchanged + (t/testing "AZERTY Shift+Digit0 already types \"0\": unchanged" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+0" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "0" :code "Digit0" :shift true}))))))) + +(t/deftest mousetrap-space-and-named-keys + (t/testing "\" \" records as space and named keys stay lowercase" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "space" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key " " :code "Space"})))) + (t/is (= "escape" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Escape" :code "Escape"})))) + (t/is (= "shift+tab" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Tab" :code "Tab" :shift true})))) + (t/is (= "del" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "Delete" :code "Delete"}))))))) + +(t/deftest mousetrap-altgr-drops-phantom-ctrl + (t/testing "Spanish AltGr++] types \"]\" with ctrl+alt flags, but the + phantom ctrl is dropped so no ctrl+alt shortcut fires" + (with-redefs [cf/check-platform? (constantly false)] + (let [event #js {:key "]" + :shiftKey false :ctrlKey true :altKey true :metaKey false + :getModifierState (fn [m] (= m "AltGraph")) + :getBrowserEvent (fn [] nil)}] + (t/is (= "alt+]" + (#'ui-shortcuts/keyboard-event->mousetrap event))))))) + +(t/deftest mousetrap-real-ctrl-alt-keeps-both + (t/testing "a real Ctrl+Alt without AltGraph still records both modifiers" + (with-redefs [cf/check-platform? (constantly false)] + (let [event #js {:key "]" + :shiftKey false :ctrlKey true :altKey true :metaKey false + :getModifierState (fn [_] false) + :getBrowserEvent (fn [] nil)}] + (t/is (= "ctrl+alt+]" + (#'ui-shortcuts/keyboard-event->mousetrap event))))))) + +(t/deftest mousetrap-spanish-shift-plus-with-layout-map-records-primary + (t/testing "Spanish layout with a layout map: Shift + the physical \"+\" + key types \"*\" but records the primary \"shift++\", which is + what the user sees and what Mousetrap matches" + (with-redefs [cf/check-platform? (constantly false)] + (try + (reset! ds/layout-map #js {:get (fn [code] (when (= code "BracketRight") "+"))}) + (t/is (= "shift++" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true})))) + (t/is (= "+" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "+" :code "BracketRight"})))) + (finally + (reset! ds/layout-map nil)))))) + +(t/deftest mousetrap-spanish-shift-plus-without-layout-map-records-glyph + (t/testing "Spanish layout without a layout map: Shift + the physical + \"+\" key records the typed glyph \"shift+*\", the same token + Mousetrap dispatches" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "shift+*" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "*" :code "BracketRight" :shift true}))))))) + +(t/deftest mousetrap-spanish-unshifted-plus-records-plus + (t/testing "Spanish layout: the unshifted physical \"+\" key records \"+\"" + (with-redefs [cf/check-platform? (constantly false)] + (t/is (= "+" + (#'ui-shortcuts/keyboard-event->mousetrap + (mock-wrapped-keydown {:key "+" :code "BracketRight"}))))))) + +(t/deftest layout-map-supported-matches-keyboard-api + (t/testing "false without a navigator or without a keyboard API" + (t/is (false? (ds/layout-map-supported? nil))) + (t/is (false? (ds/layout-map-supported? #js {}))) + (t/is (false? (ds/layout-map-supported? #js {:keyboard #js {}})))) + (t/testing "true when getLayoutMap exists (Chromium)" + (t/is (true? (ds/layout-map-supported? + #js {:keyboard #js {:getLayoutMap (fn [] nil)}}))))) diff --git a/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs b/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs deleted file mode 100644 index 9bb4479fbe..0000000000 --- a/frontend/test/frontend_tests/ui/shortcuts_recording_test.cljs +++ /dev/null @@ -1,60 +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 frontend-tests.ui.shortcuts-recording-test - (:require - [app.main.ui.shortcuts :as ss] - [cljs.test :as t :include-macros true])) - -(def ^:private keyboard-event->mousetrap - @#'ss/keyboard-event->mousetrap) - -(defn- key-event - [props] - (clj->js (merge {:altKey false :shiftKey false :ctrlKey false :metaKey false} - props))) - -(t/deftest records-alt-digit-from-key-code - ;; macOS Option+Shift+5 types "fi"; Mousetrap matches it as "5" - (t/is (= "alt+shift+5" - (keyboard-event->mousetrap - (key-event {:key "fi" :code "Digit5" :keyCode 53 :altKey true :shiftKey true}))))) - -(t/deftest records-alt-letter-from-key-code - ;; macOS Option+A types "å"; Mousetrap matches it as "a" - (t/is (= "alt+a" - (keyboard-event->mousetrap - (key-event {:key "å" :code "KeyA" :keyCode 65 :altKey true}))))) - -(t/deftest records-shift-digit-from-key-code - ;; US Shift+0 types ")"; Mousetrap matches it as "0" - (t/is (= "shift+0" - (keyboard-event->mousetrap - (key-event {:key ")" :code "Digit0" :keyCode 48 :shiftKey true}))))) - -(t/deftest records-modified-letter-from-key-code - ;; Russian layout V types "м"; Mousetrap matches Alt+V as "v" - (t/is (= "alt+v" - (keyboard-event->mousetrap - (key-event {:key "м" :code "KeyV" :keyCode 86 :altKey true}))))) - -(t/deftest records-unmodified-key-from-glyph - ;; without modifiers Mousetrap matches the typed glyph on keypress - (t/is (= "&" - (keyboard-event->mousetrap - (key-event {:key "&" :code "Digit1" :keyCode 49})))) - (t/is (= "ñ" - (keyboard-event->mousetrap - (key-event {:key "ñ" :code "" :keyCode 0}))))) - -(t/deftest records-shift-punctuation-from-code - ;; Shift+. types ":" and Shift+= types "+" (US); the key is recorded - (t/is (= "shift+." - (keyboard-event->mousetrap - (key-event {:key ":" :code "Period" :keyCode 190 :shiftKey true})))) - (t/is (= "shift+=" - (keyboard-event->mousetrap - (key-event {:key "+" :code "Equal" :keyCode 187 :shiftKey true}))))) 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"))) diff --git a/frontend/test/frontend_tests/ui/static_test.cljs b/frontend/test/frontend_tests/ui/static_test.cljs new file mode 100644 index 0000000000..acdc1b0b2c --- /dev/null +++ b/frontend/test/frontend_tests/ui/static_test.cljs @@ -0,0 +1,54 @@ +;; 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.static-test + (:require + [app.main.ui.static :as static] + [cljs.test :as t :include-macros true])) + +(t/deftest screen-kind-classifies-known-screens + ;; Regression test (penpot/penpot#11224): the exception page shows the + ;; REQUEST ACCESS modal only when it recognizes the screen the failing + ;; URL belongs to. The `screen` query param carries the route name, so + ;; the viewer is "viewer" and every team dashboard is "dashboard-*". + (t/testing "workspace" + (t/is (= :workspace (static/screen-kind {:screen "workspace"})))) + + (t/testing "bare dashboard prefix also classifies" + ;; No such route exists (routes are always "dashboard-*"), and the + ;; modal still needs loaded team/file info, so this is harmless and + ;; matches the prefix convention used in routes.cljs. + (t/is (= :dashboard (static/screen-kind {:screen "dashboard"})))) + + (t/testing "viewer" + (t/is (= :viewer (static/screen-kind {:screen "viewer"})))) + + (t/testing "dashboard variants" + (doseq [screen ["dashboard-recent" + "dashboard-members" + "dashboard-files" + "dashboard-settings"]] + (t/is (= :dashboard (static/screen-kind {:screen screen})) + (str "screen: " screen))))) + +(t/deftest screen-kind-ignores-unknown-screens + ;; Unknown, missing, or auth screens are not request-access candidates: + ;; the exception page must keep showing the plain not-found page. + (doseq [params [nil + {} + {:screen "auth-login"} + {:screen "settings-profile"} + ;; The "view" literal never was a real screen name; the + ;; exception page compared against it before the fix. + {:screen "view"}]] + (t/is (nil? (static/screen-kind params)) + (str "params: " (pr-str params))))) + +(t/deftest screen-kind-tolerates-duplicate-screen-params + ;; A repeated query param arrives as a vector; the classifier reads the + ;; scalar the same way the router does instead of crashing on it. + (t/is (= :dashboard + (static/screen-kind {:screen ["dashboard-recent" "dashboard-recent"]})))) 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 #"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

    "))) (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 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/frontend/text-editor/package.json b/frontend/text-editor/package.json index 7b0b75daa9..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 2c3702dc0c..900e2048d1 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -1238,7 +1238,7 @@ msgstr "S'ha mogut el projecte" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Aquest equip ara forma part de l'organització %s" +msgstr "%s ara forma part de %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1251,7 +1251,7 @@ msgstr "Membres de l'equip" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Aquest equip ja no forma part de l'organització %s" +msgstr "%s ja no forma part de %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 6aae6ad1c5..b04434f020 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -360,6 +360,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" @@ -1230,7 +1234,7 @@ msgstr "Your project has been moved successfully" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "This team is now part of the organization %s" +msgstr "%s now belongs to %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1243,7 +1247,7 @@ msgstr "Team members" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "This team is no longer part of the organization %s" +msgstr "%s is no longer part of %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" @@ -1512,7 +1516,7 @@ msgstr "There was an error with the comment" msgid "errors.connection-error" msgstr "Cannot reach the server. Check your connection and try again." -#: src/app/main/errors.cljs:851 +#: src/app/main/errors.cljs:414 msgid "errors.deprecated" msgstr "" "Sorry! This is an old file that uses a deprecated type of Penpot assets and " @@ -1562,7 +1566,15 @@ msgstr "The email «%s» has many permanent bounce reports." msgid "errors.email-spam-or-permanent-bounces" msgstr "The email «%s» has been reported as spam or permanently bounce." -#: src/app/main/errors.cljs:828 +#: src/app/main/data/exports/assets.cljs +msgid "errors.export-failed" +msgstr "The export failed. Try again in a moment." + +#: src/app/main/data/exports/assets.cljs:346 +msgid "errors.export-too-large" +msgstr "Too large to export, try a smaller scale" + +#: src/app/main/errors.cljs:974 msgid "errors.feature-mismatch" msgstr "" "Looks like you are opening a file that has the feature '%s' enabled but the " @@ -1738,8 +1750,16 @@ msgstr "The registration is currently disabled." #: src/app/main/errors.cljs:549 msgid "errors.save-failed" msgstr "" -"Autosave is not working due to an error. Contact support to report the " -"error and reload to continue from your last saved version." +"Autosave has stopped for this session, so your changes since the last successful save cannot be saved. Download the report to help us fix it, then reload the page, or leave this file and open it again, to keep editing." + +#: src/app/main/data/persistence.cljs:310 +msgid "errors.save-retrying" +msgstr "Connection lost. Retrying to save your changes." + +#: src/app/main/errors.cljs +msgid "errors.save-still-retrying" +msgstr "" +"Your changes are not saved yet because Penpot cannot reach the server. They are kept, and Penpot will keep trying to save them. Keep this tab open until they are saved." #: src/app/main/errors.cljs:542 msgid "errors.save-refused-archived-branch" @@ -1808,6 +1828,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" @@ -1820,10 +1844,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" @@ -1917,6 +1949,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." @@ -2124,6 +2160,10 @@ msgstr "Your file will be exported with library asset merged into the local libr msgid "files-export-modal.options.merge-libraries.title" msgstr "Embed library assets in the file" +#: src/app/main/ui/exports/files.cljs +msgid "files-download-modal.unsaved-changes" +msgstr "Some of your latest changes are not saved yet, so this download will not include them." + #: src/app/main/ui/settings/password.cljs:45 msgid "generic.error" msgstr "An error has occurred" @@ -2152,6 +2192,10 @@ msgstr "This file is not a valid shortcuts export." msgid "import-shortcuts.no-changes" msgstr "No differences found between the current and imported shortcuts." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "These entries can't be imported: they don't match any Penpot shortcut or use a reserved key combination." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -2260,6 +2304,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" @@ -2927,6 +2975,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" @@ -4961,6 +5013,10 @@ msgstr "Edit %s" msgid "shortcuts.edit-modal.conflict" msgstr "Already assigned to \"%s\". Saving will move it to this action." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "This combination is reserved by Penpot and can't be assigned." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Saving will reset this shortcut to default:" @@ -5057,6 +5113,13 @@ msgstr "Join nodes" msgid "shortcuts.key-combo" msgstr "Press the new keys" +#: src/app/main/ui/settings/shortcuts.cljs:484 +msgid "shortcuts.layout-map-missing" +msgstr "" +"This browser does not expose the keyboard layout, so shortcuts that " +"combine Shift with punctuation (for example Shift+,) may not work here. " +"They keep working in Chromium browsers." + #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Toggle line through" @@ -5834,7 +5897,7 @@ msgstr "" #: src/app/main/ui/settings/subscription.cljs:714 msgid "subscription.settings.member-since" -msgstr "Penpot member since: %s" +msgstr "Penpot member since: %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" @@ -5904,6 +5967,9 @@ msgstr "Subscribe" msgid "subscription.settings.subscribed-since" msgstr "You've been on the %s plan since %s." +msgid "subscription.settings.trial-ends-on" +msgstr "Your free trial ends on %s." + #: src/app/main/ui/settings/subscription.cljs:399 msgid "subscription.settings.success.dialog.description" msgstr "" @@ -6061,6 +6127,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)" @@ -6101,6 +6171,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" @@ -7487,7 +7565,11 @@ msgstr "Remove guide" msgid "workspace.debug.title" msgstr "Debugging tools" -#: src/app/main/ui/workspace/sidebar/layers.cljs:885 +#: src/app/main/data/exports/assets.cljs +msgid "workspace.export.unsaved-changes" +msgstr "Some of your latest changes are not saved yet, so this export may not include them." + +#: src/app/main/ui/workspace/sidebar/layers.cljs:907 msgid "workspace.focus.focus-mode" msgstr "Focus mode" @@ -7699,7 +7781,7 @@ msgstr "Reset" msgid "workspace.header.retrying" msgstr "Retrying to save" -#: src/app/main/ui/workspace/left_header.cljs:126 +#: src/app/main/ui/workspace/left_header.cljs:123 msgid "workspace.header.save-error" msgstr "Error on saving" @@ -7743,6 +7825,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" @@ -7779,6 +7869,18 @@ 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" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Editing grid" @@ -8385,6 +8487,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" @@ -8785,6 +8891,10 @@ msgstr "More library colors" msgid "workspace.options.more-token-colors" msgstr "More color tokens" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:502 +msgid "workspace.options.no-font-results" +msgstr "No matching fonts" + #: 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 "No matching size preset" @@ -9315,6 +9425,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:" @@ -10014,6 +10136,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" @@ -10641,6 +10767,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)" @@ -10690,6 +10820,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 d0f920927f..6785bbeb55 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" @@ -1236,7 +1240,7 @@ msgstr "Tu proyecto ha sido movido con éxito" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Este equipo ahora es parte de la organización %s" +msgstr "%s ahora pertenece a %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1249,7 +1253,7 @@ msgstr "Integrantes del equipo" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Este equipo ya no es parte de la organización %s" +msgstr "%s ya no forma parte de %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" @@ -1570,7 +1574,15 @@ msgstr "El correo electrónico «%s» tiene varios reportes de rebote permanente msgid "errors.email-spam-or-permanent-bounces" msgstr "El email «%s» tiene reportes de spam o de rebote permanente." -#: src/app/main/errors.cljs:828 +#: src/app/main/data/exports/assets.cljs +msgid "errors.export-failed" +msgstr "La exportación ha fallado. Inténtalo de nuevo en un momento." + +#: src/app/main/data/exports/assets.cljs:346 +msgid "errors.export-too-large" +msgstr "El archivo es demasiado grande, prueba con otra escala" + +#: src/app/main/errors.cljs:974 msgid "errors.feature-mismatch" msgstr "" "Parece que está abriendo un archivo que tiene la función '%s' habilitada, " @@ -1756,9 +1768,12 @@ msgstr "El registro está actualmente desactivado." #: src/app/main/errors.cljs:538 msgid "errors.save-failed" msgstr "" -"El autoguardado se ha detenido debido a un error. Contacta soporte para " -"informar del error y recarga para continuar desde los últimos cambios " -"guardados." +"El autoguardado se ha detenido en esta sesión, así que los cambios desde el último guardado correcto no se podrán guardar. Descarga el informe para ayudarnos a arreglarlo y recarga la página, o sal de este fichero y vuelve a abrirlo, para seguir editando." + +#: src/app/main/errors.cljs +msgid "errors.save-still-retrying" +msgstr "" +"Tus cambios aún no se han guardado porque Penpot no puede conectar con el servidor. Se conservan y Penpot seguirá intentando guardarlos. Mantén esta pestaña abierta hasta que se guarden." #: src/app/main/data/persistence.cljs:307 msgid "errors.save-retrying" @@ -1826,6 +1841,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" @@ -1838,10 +1857,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" @@ -1936,6 +1963,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 %." @@ -2146,6 +2177,10 @@ msgstr "" msgid "files-export-modal.options.merge-libraries.title" msgstr "Incluir librerias compartidas dentro de las librerias del fichero" +#: src/app/main/ui/exports/files.cljs +msgid "files-download-modal.unsaved-changes" +msgstr "Algunos de tus últimos cambios aún no se han guardado, así que esta descarga no los incluirá." + #: src/app/main/ui/settings/password.cljs:45 msgid "generic.error" msgstr "Ha ocurrido un error" @@ -2174,6 +2209,10 @@ msgstr "Este archivo no es una exportación de atajos válida." msgid "import-shortcuts.no-changes" msgstr "No se encontraron diferencias entre los atajos actuales y los importados." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "Estas entradas no se pueden importar: no corresponden a ningún atajo de Penpot o usan una combinación reservada." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -2282,6 +2321,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" @@ -2951,6 +2994,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" @@ -5006,6 +5053,10 @@ msgstr "Editar %s" msgid "shortcuts.edit-modal.conflict" msgstr "Ya asignado a \"%s\". Al guardar, pasará a esta acción." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "Penpot reserva esta combinación y no se puede asignar." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Al guardar, este atajo volverá a su valor por defecto:" @@ -5102,6 +5153,13 @@ msgstr "Unir nodos" msgid "shortcuts.key-combo" msgstr "Pulsa las nuevas teclas" +#: src/app/main/ui/settings/shortcuts.cljs:484 +msgid "shortcuts.layout-map-missing" +msgstr "" +"Este navegador no expone el mapa de teclado, así que los atajos que " +"combinan Shift con puntuación (por ejemplo Shift+,) pueden no funcionar " +"aquí. Siguen funcionando en navegadores Chromium." + #: src/app/main/data/workspace/text/shortcuts.cljs:249 msgid "shortcuts.line-through" msgstr "Alternar tachado" @@ -5890,7 +5948,7 @@ msgstr "" #: src/app/main/ui/settings/subscription.cljs:714 msgid "subscription.settings.member-since" -msgstr "Miembro de penpot desde: %s" +msgstr "Miembro de penpot desde: %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" @@ -5962,6 +6020,9 @@ msgstr "Suscríbete" msgid "subscription.settings.subscribed-since" msgstr "Llevas en el plan %s desde %s." +msgid "subscription.settings.trial-ends-on" +msgstr "Tu prueba gratuita acaba el %s." + #: src/app/main/ui/settings/subscription.cljs:399 msgid "subscription.settings.success.dialog.description" msgstr "" @@ -6119,6 +6180,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)" @@ -6159,6 +6224,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" @@ -6584,7 +6657,11 @@ msgstr "Eliminar guía" msgid "workspace.debug.title" msgstr "Herramientas de depuración" -#: src/app/main/ui/workspace/sidebar/layers.cljs:885 +#: src/app/main/data/exports/assets.cljs +msgid "workspace.export.unsaved-changes" +msgstr "Algunos de tus últimos cambios aún no se han guardado, así que puede que esta exportación no los incluya." + +#: src/app/main/ui/workspace/sidebar/layers.cljs:907 msgid "workspace.focus.focus-mode" msgstr "Modo foco" @@ -6840,6 +6917,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" @@ -6876,6 +6961,18 @@ 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" + #: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:61 msgid "workspace.layout-grid.editor.title" msgstr "Editando rejilla" @@ -7485,6 +7582,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" @@ -7885,6 +7986,10 @@ msgstr "Más colores de la biblioteca" msgid "workspace.options.more-token-colors" msgstr "Más tokens de color" +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:502 +msgid "workspace.options.no-font-results" +msgstr "No hay fuentes coincidentes" + #: 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 "No hay tamaños predefinidos coincidentes" @@ -8418,6 +8523,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:" @@ -8827,6 +8944,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" @@ -9469,6 +9590,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)" @@ -9518,6 +9643,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" diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index 2acda65b41..bf01868ef6 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -1220,7 +1220,7 @@ msgstr "پروژه شما با موفقیت منتقل شد" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "این تیم اکنون به %s تعلق دارد" +msgstr "%s اکنون به %s تعلق دارد" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1233,7 +1233,7 @@ msgstr "اعضای تیم" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "این تیم دیگر به سازمان %s تعلق ندارد" +msgstr "%s دیگر به %s تعلق ندارد" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 507dfc85bb..5686f05331 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1248,7 +1248,7 @@ msgstr "Votre projet a bien été déplacé" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Cette équipe appartient désormais à %s" +msgstr "%s appartient désormais à %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1261,7 +1261,7 @@ msgstr "Membres de l’équipe" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Cette équipe n'appartient plus à l'organisation %s" +msgstr "%s n'appartient plus à %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 65470f996a..79fb1a0323 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -1122,7 +1122,7 @@ msgstr "Le projet a été déplacé avec succès" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Cette équipe appartient maintenant à l'organisation %s" +msgstr "%s appartient maintenant à %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1135,7 +1135,7 @@ msgstr "Membres de l'équipe" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Cette équipe n'appartient plus à l'organisation %s" +msgstr "%s n'appartient plus à %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index 468980a6f6..0e9eabca97 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -1093,7 +1093,7 @@ msgstr "המיזם שלך הועבר בהצלחה" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "הצוות הזה שייך מעתה ל־%s" +msgstr "%s שייך מעתה ל־%s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1106,7 +1106,7 @@ msgstr "חברי הצוות" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "הצוות הזה לא שייך יותר לארגון %s" +msgstr "%s לא שייך יותר ל־%s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index 992dc61a58..3512c5d007 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -1103,7 +1103,7 @@ msgstr "Il tuo progetto è stato spostato con successo" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Questo team ora appartiene a %s" +msgstr "%s ora appartiene a %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1116,7 +1116,7 @@ msgstr "Membri del team" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Questo team non appartiene più all'organizzazione %s" +msgstr "%s non appartiene più a %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 1d633bb75d..7b0a3d78b9 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -1202,7 +1202,7 @@ msgstr "プロジェクトを移動しました" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "このチームは組織「%s」に所属しました" +msgstr "%s は組織「%s」に所属しました" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1215,7 +1215,7 @@ msgstr "チームのメンバー" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "このチームは組織「%s」に所属しなくなりました" +msgstr "%s は組織「%s」に所属しなくなりました" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 1de2d611ec..3d55474017 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -1230,7 +1230,7 @@ msgstr "Je project is succesvol verplaatst" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Dit team behoort nu tot %s" +msgstr "%s behoort nu tot %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1243,7 +1243,7 @@ msgstr "Teamleden" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Dit team behoort niet langer tot de organisatie %s" +msgstr "%s behoort niet langer tot %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index b74e6d85a0..293b654895 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -1221,7 +1221,7 @@ msgstr "Ditt projekt har flyttats" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Detta team tillhör nu %s" +msgstr "%s tillhör nu %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1234,7 +1234,7 @@ msgstr "Teammedlemmar" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Detta team tillhör inte längre organisation %s" +msgstr "%s tillhör inte längre %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index 82cd5fe9a6..adbc51754c 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -1226,7 +1226,7 @@ msgstr "Projen başarıyla taşındı" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "Bu takım artık %s organizasyonuna ait" +msgstr "%s artık %s organizasyonuna ait" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1239,7 +1239,7 @@ msgstr "Takım üyeleri" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "Bu takım artık %s organizasyonuna ait değil" +msgstr "%s artık %s organizasyonuna ait değil" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index 94bb9de6fa..ebb8577137 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -1163,7 +1163,7 @@ msgstr "已成功移動您的專案" #: src/app/main/data/common.cljs:266 #, backend msgid "dashboard.team-belong-organization" -msgstr "此團隊現已隸屬於組織 %s" +msgstr "%s 現已隸屬於 %s" #: src/app/main/ui/dashboard/team.cljs:1669 msgid "dashboard.team-info" @@ -1176,7 +1176,7 @@ msgstr "團隊成員" #: src/app/main/data/common.cljs:267 #, backend msgid "dashboard.team-no-longer-belong-organization" -msgstr "此團隊已不再隸屬於組織 %s" +msgstr "%s 已不再隸屬於 %s" #: src/app/main/ui/dashboard/team.cljs:1676 msgid "dashboard.team-organization" diff --git a/library/package.json b/library/package.json index 6f9c8b368d..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 6cee8a1b9a..d8a67b195d 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/.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 diff --git a/mcp/package.json b/mcp/package.json index 980f26ce29..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 e91b9ea873..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 383f8cb76d..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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/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..69715ca183 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: |- ``` @@ -22735,10 +23033,12 @@ TokenCatalog: readonly isEditableTokens: boolean ``` - Returns true if the tokens can be edited (it can't - if the tokens of the file are in an external library). - Note that even in this case, Themes and Sets status still - can be changed and tokens can be applied to shapes. + Returns true if the tokens in this catalog can be edited. Only the + catalog of the current file (`penpot.library.local.tokens`) can be + edited, and only when the file is its own tokens source. + When the tokens come from an external library, the local catalog + can still change the active themes and sets, and its tokens can + still be applied to shapes. themes: |- ``` readonly themes: TokenTheme[] @@ -22873,7 +23173,8 @@ TokenSet: active: boolean ``` - Indicates if the set is currently active. + Indicates if the set is active in the file that owns this catalog. + Only sets from `penpot.library.local.tokens` can be changed. tokens: |- ``` readonly tokens: Token[] @@ -22893,6 +23194,7 @@ TokenSet: ``` Toggles the active status of this set. + Only sets from `penpot.library.local.tokens` can be changed. Returns void getTokenById: |- @@ -23016,7 +23318,8 @@ TokenTheme: active: boolean ``` - Indicates if the theme is currently active. + Indicates if the theme is active in the file that owns this catalog. + Only themes from `penpot.library.local.tokens` can be changed. activeSets: |- ``` readonly activeSets: TokenSet[] @@ -23030,6 +23333,7 @@ TokenTheme: ``` Toggles the active status of this theme. + Only themes from `penpot.library.local.tokens` can be changed. Returns void addSet: |- @@ -23606,6 +23910,7 @@ LibraryContext: type LibraryContext = { local: Library; connected: Library[]; + tokensSource: Library; availableLibraries(): Promise; connectLibrary(libraryId: string): Promise; } @@ -23636,6 +23941,33 @@ LibraryContext: ``` const connectedLibraries = libraryContext.connected; ``` + tokensSource: |- + ``` + tokensSource: Library + ``` + + The library that supplies tokens to the current file. + Defaults to the local library. + + Assign a connected library that has its own tokens to use it as the + source. This copies its active themes and sets to the current file. + Assign `local` to use the file's own tokens again. Themes and sets + that don't exist in the local tokens are deactivated. + Setting the source requires `content:write` permission and supports undo. + Assigning the current source again keeps the active themes and sets. + + The catalog of an external `tokensSource` is read-only and reports the + library's own active themes and sets. To change which ones are active + in the current file, use `penpot.library.local.tokens`, which lists the + tokens of the source. + + Example + ``` + const source = await penpot.library.connectLibrary('library-id'); + penpot.library.tokensSource = source; + // Restore local tokens: + penpot.library.tokensSource = penpot.library.local; + ``` Methods: availableLibraries: |- ``` 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/mcp/packages/server/package.json b/mcp/packages/server/package.json index 33a08f336c..ee816a759b 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -27,7 +27,7 @@ "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 9cf2bda79d..7b7f98e50e 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/types-generator/prepare_api_docs.py b/mcp/types-generator/prepare_api_docs.py index a01ec4e6ef..143f110c44 100644 --- a/mcp/types-generator/prepare_api_docs.py +++ b/mcp/types-generator/prepare_api_docs.py @@ -63,6 +63,8 @@ class PenpotAPIContentMarkdownConverter(MarkdownConverter): if node.name == "pre": for button in soup.find_all("button"): button.decompose() + for br in soup.find_all("br"): + br.replace_with("\n") return f"\n```\n{soup.get_text().strip()}\n```\n\n" # convert tsd-signature elements to code blocks, converting
    to newlines diff --git a/mcp/types-generator/test_prepare_api_docs.py b/mcp/types-generator/test_prepare_api_docs.py new file mode 100644 index 0000000000..bb2fc631eb --- /dev/null +++ b/mcp/types-generator/test_prepare_api_docs.py @@ -0,0 +1,31 @@ +import unittest + +from prepare_api_docs import PenpotAPIContentMarkdownConverter + + +class PenpotAPIContentMarkdownConverterTest(unittest.TestCase): + def test_preserves_line_breaks_in_code_examples(self): + html = ( + '
    '
    +            'const source = await penpot.library.connectLibrary("library-id");
    ' + 'penpot.library.tokensSource = source;
    ' + '// Restore local tokens:
    ' + 'penpot.library.tokensSource = penpot.library.local;' + '
    ' + ) + + result = PenpotAPIContentMarkdownConverter().convert(html).strip() + + self.assertEqual( + result, + '```\n' + 'const source = await penpot.library.connectLibrary("library-id");\n' + 'penpot.library.tokensSource = source;\n' + '// Restore local tokens:\n' + 'penpot.library.tokensSource = penpot.library.local;\n' + '```', + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/media-processor/package.json b/media-processor/package.json index f5373a591f..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 56410eee5c..147a963c3e 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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 5f6e9cbec0..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index ac3ecf08a0..f74e320625 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,8 +2,11 @@ ### 🚀 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 `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission. +- **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method. +- **plugin-types:** Added `LibraryContext.tokensSource` to read or set the current file's token source to a connected library, or restore local tokens. - **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 @@ -11,7 +14,10 @@ ### 🩹 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:** `LibraryComponent.remove()` now rejects linked-library and missing component handles, and does nothing for an already deleted component. - **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. @@ -20,7 +26,9 @@ - **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. +- **plugins-runtime**: `Shape.applyToken()`, `Token.applyToShapes()` and `Token.applyToSelected()` now reject an explicit per-side stroke width property (`strokeWidthTop`, `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) on shapes other than boards and rectangles, matching the `strokes` setter. The uniform `strokeWidth` property keeps working on every shape. Closes #12319. ## 1.5.0 (2026-07-08) diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index e26ce1c6ca..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "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 18b76cc3a0..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index aa177c08d3..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "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 961a360e9f..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index c3e0d53630..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 73f65ac488..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 2bf7416462..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "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 56989beb20..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } 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/package.json b/plugins/apps/plugin-api-test-suite/package.json index f849cbed1d..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } 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..46d748bfc4 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": [ @@ -273,6 +275,7 @@ "createColor", "createComponent", "createTypography", + "deletedComponents", "id", "name", "tokens", @@ -287,16 +290,21 @@ "opacity" ], "LibraryComponent": [ + "annotation", + "duplicate", "instance", "isVariant", "mainInstance", + "remove", + "restore", "transformInVariant" ], "LibraryContext": [ "availableLibraries", "connectLibrary", "connected", - "local" + "local", + "tokensSource" ], "LibraryElement": ["id", "libraryId", "name", "path"], "LibrarySummary": [ @@ -365,6 +373,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 +393,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"], @@ -454,6 +497,7 @@ "swapComponent", "switchVariant", "tokens", + "updateMain", "visible", "waitForLayoutUpdate", "width", @@ -480,6 +524,14 @@ "strokeWidthType" ], "SvgRaw": ["type"], + "Team": [ + "createProject", + "id", + "isDefault", + "listProjects", + "name", + "remove" + ], "Text": [ "align", "applyTypography", @@ -538,6 +590,7 @@ "addTheme", "getSetById", "getThemeById", + "isEditableTokens", "sets", "themes" ], @@ -636,7 +689,8 @@ "zoomIntoView", "zoomReset", "zoomToFitAll" - ] + ], + "WorkspaceContext": ["fileId", "fileName", "status", "teamId"] }, "graph": { "ActiveUser": { @@ -1124,6 +1178,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -1630,6 +1690,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -2080,6 +2146,12 @@ } }, "Context": { + "management": { + "decl": "Context", + "kind": "get", + "type": null, + "array": false + }, "version": { "decl": "Context", "kind": "get", @@ -2774,6 +2846,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -2914,6 +2992,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", @@ -4022,6 +4112,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -4612,6 +4708,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -4994,6 +5096,12 @@ "type": "LibraryComponent", "array": true }, + "deletedComponents": { + "decl": "Library", + "kind": "get", + "type": "LibraryComponent", + "array": true + }, "tokens": { "decl": "Library", "kind": "get", @@ -5154,6 +5262,30 @@ } }, "LibraryComponent": { + "annotation": { + "decl": "LibraryComponent", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "LibraryComponent", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "remove": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "restore": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, "instance": { "decl": "LibraryComponent", "kind": "method", @@ -5252,6 +5384,12 @@ "type": "Library", "array": true }, + "tokensSource": { + "decl": "LibraryContext", + "kind": "getset", + "type": "Library", + "array": false + }, "availableLibraries": { "decl": "LibraryContext", "kind": "method", @@ -5524,6 +5662,30 @@ "type": null, "array": false }, + "annotation": { + "decl": "LibraryComponent", + "kind": "getset", + "type": null, + "array": false + }, + "duplicate": { + "decl": "LibraryComponent", + "kind": "method", + "type": "LibraryComponent", + "array": false + }, + "remove": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, + "restore": { + "decl": "LibraryComponent", + "kind": "method", + "type": null, + "array": false + }, "instance": { "decl": "LibraryComponent", "kind": "method", @@ -6246,6 +6408,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -6677,6 +6845,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 +6955,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", @@ -7088,6 +7436,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -7622,6 +7976,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -8222,6 +8582,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -8361,6 +8727,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", @@ -8794,6 +9198,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -9274,6 +9684,12 @@ } }, "TokenCatalog": { + "isEditableTokens": { + "decl": "TokenCatalog", + "kind": "get", + "type": null, + "array": false + }, "themes": { "decl": "TokenCatalog", "kind": "get", @@ -11098,6 +11514,12 @@ "type": null, "array": false }, + "updateMain": { + "decl": "ShapeBase", + "kind": "method", + "type": null, + "array": false + }, "switchVariant": { "decl": "ShapeBase", "kind": "method", @@ -11330,6 +11752,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/components.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/components.test.ts index ecdd6c5118..92d68fd6aa 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 @@ -199,6 +199,66 @@ describe('Component instances', () => { } }); + test('restore restores a deleted component without changing the view', async (ctx) => { + const originalPage = ctx.penpot.currentPage; + expect(originalPage).not.toBeNull(); + if (!originalPage) return; + + const comp = makeComponent(ctx); + const main = comp.mainInstance() as Board; + const mainId = main.id; + const childIds = main.children.map((child) => child.id); + const inst = comp.instance(); + ctx.board.appendChild(inst); + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(false); + expect(originalPage.getShapeById(mainId)).toBeNull(); + + const otherPage = ctx.penpot.createPage(); + try { + await ctx.penpot.openPage(otherPage); + const selected = ctx.penpot.createRectangle(); + (otherPage.root as Board).appendChild(selected); + ctx.penpot.selection = [selected]; + ctx.penpot.viewport.zoom = 2; + ctx.penpot.viewport.center = { x: 300, y: 200 }; + await ctx.penpot.waitForLayoutUpdate(); + + const zoom = ctx.penpot.viewport.zoom; + const center = ctx.penpot.viewport.center; + const deleted = inst.component(); + expect(deleted).not.toBeNull(); + if (!deleted) return; + + deleted.restore(); + await waitFor(() => + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ); + await ctx.penpot.waitForLayoutUpdate(); + + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(true); + expect(originalPage.getShapeById(mainId)?.id).toBe(mainId); + expect( + (deleted.mainInstance() as Board).children.map((child) => child.id), + ).toEqual(childIds); + expect(ctx.penpot.currentPage?.id).toBe(otherPage.id); + expect(ctx.penpot.selection.map((shape) => shape.id)).toEqual([ + selected.id, + ]); + expect(ctx.penpot.viewport.zoom).toBeCloseTo(zoom); + expect(ctx.penpot.viewport.center.x).toBeCloseTo(center.x); + expect(ctx.penpot.viewport.center.y).toBeCloseTo(center.y); + } finally { + await ctx.penpot.openPage(originalPage); + otherPage.remove(); + } + }); + test('detach turns an instance into a basic shape', (ctx) => { const inst = instanceOf(ctx); inst.detach(); @@ -319,3 +379,177 @@ describe('Component instances', () => { expect(inst.isComponentInstance()).toBeFalsy(); }); }); + +describe('Component assets', () => { + test('duplicate creates an independent component with its annotation', async (ctx) => { + const source = makeComponent(ctx); + source.annotation = 'Button usage'; + const original = source.mainInstance() as Board; + const duplicate = source.duplicate(); + await ctx.penpot.waitForLayoutUpdate(); + const main = duplicate.mainInstance() as Board; + ctx.board.appendChild(main); + + expect(duplicate.id).not.toBe(source.id); + expect(main.id).not.toBe(original.id); + expect(main.isComponentMainInstance()).toBe(true); + expect(main.component()?.id).toBe(duplicate.id); + expect(main.children).toHaveLength(original.children.length); + expect(main.children[0].id).not.toBe(original.children[0].id); + expect(duplicate.annotation).toBe('Button usage'); + duplicate.name = 'Duplicate button'; + expect(source.name).not.toBe(duplicate.name); + }); + + test('annotations can be created, edited, and removed', async (ctx) => { + const comp = makeComponent(ctx); + expect(comp.annotation).toBeNull(); + comp.annotation = 'Use for primary actions'; + expect(comp.annotation).toBe('Use for primary actions'); + comp.annotation = 'Use for secondary actions'; + expect(comp.annotation).toBe('Use for secondary actions'); + expect(() => { + comp.annotation = 42 as unknown as string; + }).toThrow(); + expect(() => { + comp.annotation = 'x'.repeat(301); + }).toThrow(); + expect(comp.annotation).toBe('Use for secondary actions'); + comp.annotation = null; + await ctx.penpot.waitForLayoutUpdate(); + expect(comp.annotation).toBeNull(); + }); + + test('deletedComponents lists and restores a component without surviving copies', async (ctx) => { + const comp = makeComponent(ctx); + const mainId = comp.mainInstance().id; + const page = ctx.penpot.currentPage; + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect(page?.getShapeById(mainId)).toBeNull(); + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(false); + + const deleted = ctx.penpot.library.local.deletedComponents.find( + (c) => c.id === comp.id, + ); + expect(deleted).toBeDefined(); + if (!deleted) return; + deleted.remove(); + deleted.restore(); + await ctx.penpot.waitForLayoutUpdate(); + expect(page?.getShapeById(mainId)?.id).toBe(mainId); + expect( + ctx.penpot.library.local.components.some((c) => c.id === comp.id), + ).toBe(true); + expect( + ctx.penpot.library.local.deletedComponents.some((c) => c.id === comp.id), + ).toBe(false); + }); + + test('updateMain applies a copy override to the main and other copies', async (ctx) => { + const childColor = (board: Board) => { + const fill = board.children[0].fills[0]; + return typeof fill === 'string' ? fill : fill?.fillColor; + }; + const comp = makeComponent(ctx); + const main = comp.mainInstance() as Board; + const copy = comp.instance() as Board; + const other = comp.instance() as Board; + ctx.board.appendChild(copy); + ctx.board.appendChild(other); + copy.children[0].fills = [{ fillColor: '#ff0000', fillOpacity: 1 }]; + copy.updateMain(); + await ctx.penpot.waitForLayoutUpdate(); + await waitFor(() => childColor(main) === '#ff0000'); + expect(childColor(main)).toBe('#ff0000'); + expect(childColor(other)).toBe('#ff0000'); + + main.children[0].fills = [{ fillColor: '#00ff00', fillOpacity: 1 }]; + await waitFor(() => childColor(copy) === '#00ff00'); + expect(childColor(copy)).toBe('#00ff00'); + }); + + test('updateMain rejects plain shapes, main instances, and copy children', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + expect(() => rect.updateMain()).toThrow(); + const comp = makeComponent(ctx); + expect(() => comp.mainInstance().updateMain()).toThrow(); + const copy = comp.instance() as Board; + ctx.board.appendChild(copy); + expect(() => copy.children[0].updateMain()).toThrow(); + }); + + test('deleted components reject duplication, annotation edits, and updateMain', async (ctx) => { + const comp = makeComponent(ctx); + const copy = comp.instance(); + ctx.board.appendChild(copy); + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect(() => comp.duplicate()).toThrow('deleted component'); + expect(() => { + comp.annotation = 'Usage'; + }).toThrow('deleted component'); + expect(() => copy.updateMain()).toThrow('main component'); + }); + + test('updateMain rejects a copy on an inactive page and inside another copy', async (ctx) => { + const main = nestedComponentMain(ctx); + const outerCopy = main.component()?.instance() as Board; + ctx.board.appendChild(outerCopy); + expect(() => outerCopy.children[0].updateMain()).toThrow( + 'inside another component copy', + ); + + const original = ctx.penpot.currentPage; + expect(original).not.toBeNull(); + if (!original) return; + const page = ctx.penpot.createPage(); + try { + await ctx.penpot.openPage(page); + expect(() => outerCopy.updateMain()).toThrow('not currently active'); + } finally { + await ctx.penpot.openPage(original); + page.remove(); + } + }); + + test('component assets can be edited without activating their main instance page', async (ctx) => { + const original = ctx.penpot.currentPage; + expect(original).not.toBeNull(); + if (!original) return; + const comp = makeComponent(ctx); + const mainId = comp.mainInstance().id; + const page = ctx.penpot.createPage(); + try { + await ctx.penpot.openPage(page); + const selected = ctx.penpot.createRectangle(); + (page.root as Board).appendChild(selected); + ctx.penpot.selection = [selected]; + const zoom = ctx.penpot.viewport.zoom; + const center = ctx.penpot.viewport.center; + + comp.annotation = 'Edited from another page'; + const duplicate = comp.duplicate(); + await ctx.penpot.waitForLayoutUpdate(); + expect(original.getShapeById(duplicate.mainInstance().id)).not.toBeNull(); + expect(comp.annotation).toBe('Edited from another page'); + duplicate.remove(); + comp.remove(); + await ctx.penpot.waitForLayoutUpdate(); + expect(original.getShapeById(mainId)).toBeNull(); + expect(ctx.penpot.currentPage?.id).toBe(page.id); + expect(ctx.penpot.selection.map((shape) => shape.id)).toEqual([ + selected.id, + ]); + expect(ctx.penpot.viewport.zoom).toBeCloseTo(zoom); + expect(ctx.penpot.viewport.center.x).toBeCloseTo(center.x); + expect(ctx.penpot.viewport.center.y).toBeCloseTo(center.y); + } finally { + await ctx.penpot.openPage(original); + page.remove(); + } + }); +}); 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/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 095a86ac17..0f5faec89f 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({ @@ -456,6 +481,55 @@ describe('Tokens', () => { expect(keys).not.toContain('strokeWidthRight'); }); + // Issue #12319: per-side widths are only supported on boards and + // rectangles. `applyToken` used to bind them on any shape, leaving an + // ellipse in the per-side state the `strokes` setter rejects. + test('applyToken rejects an explicit per-side width on an ellipse', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + + expect(() => ellipse.applyToken(token, ['strokeWidthTop'])).toThrow(); + expect(ellipse.tokens.strokeWidthTop).toBeUndefined(); + }); + + test('applyToShapes rejects an explicit per-side width on an ellipse', (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + + expect(() => + token.applyToShapes([ellipse], ['strokeWidthLeft']), + ).toThrow(); + expect(ellipse.tokens.strokeWidthLeft).toBeUndefined(); + }); + + test('applyToken keeps accepting the uniform strokeWidth alias on an ellipse', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const ellipse = ctx.penpot.createEllipse(); + ctx.board.appendChild(ellipse); + + ellipse.applyToken(token, ['strokeWidth']); + + await waitFor(() => ellipse.tokens.strokeWidth === token.name); + expect(ellipse.tokens.strokeWidth).toBe(token.name); + }); + for (const side of [ 'strokeWidthTop', 'strokeWidthRight', @@ -730,6 +804,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({ 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/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 6cd5d0cf94..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "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 dd52a6e2d3..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "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 2ac3b42cbc..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 3fc0a470ef..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 135defef1f..5d6c27e156 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. * @@ -2795,6 +2937,12 @@ export interface Library extends PluginData { */ readonly components: LibraryComponent[]; + /** + * Deleted components, including individual deleted variants. + * Use a component's `restore()` method to restore it. + */ + readonly deletedComponents: LibraryComponent[]; + /** * A catalog of Design Tokens in the library. * @@ -2890,6 +3038,39 @@ export interface LibraryColor extends LibraryElement { * This interface extends `LibraryElement` and includes properties specific to component elements. */ export interface LibraryComponent extends LibraryElement { + /** + * The component annotation, or null when none is set. + * Set null to delete it. Text can contain at most 300 characters. + * Writing requires `library:write` permission and a live component in the + * current file's library. + */ + annotation: string | null; + + /** + * Duplicates this component and its main instance in the current file's library. + * Requires `library:write` permission. The component must not be deleted. + */ + duplicate(): LibraryComponent; + + /** + * Deletes this component and its main instance from the current file's library. + * Requires `library:write` permission. Does nothing if already deleted. + * Surviving copies keep their link so the component can be restored later. + */ + remove(): void; + + /** + * Restores this component if it has been deleted, including its main instance. + * Leaves the current page, selection, zoom, and viewport unchanged. + * Requires `library:write` permission. Does nothing if already restored. + * + * @example + * ```js + * penpot.selection[0].component()?.restore(); + * ``` + */ + restore(): void; + /** * Creates an instance of the component. * @return Returns a `Shape` object representing the instance of the component. @@ -2980,6 +3161,32 @@ export type LibraryContext = { */ readonly connected: Library[]; + /** + * The library that supplies tokens to the current file. + * Defaults to the local library. + * + * Assign a connected library that has its own tokens to use it as the + * source. This copies its active themes and sets to the current file. + * Assign `local` to use the file's own tokens again. Themes and sets + * that don't exist in the local tokens are deactivated. + * Setting the source requires `content:write` permission and supports undo. + * Assigning the current source again keeps the active themes and sets. + * + * The catalog of an external `tokensSource` is read-only and reports the + * library's own active themes and sets. To change which ones are active + * in the current file, use `penpot.library.local.tokens`, which lists the + * tokens of the source. + * + * @example + * ```js + * const source = await penpot.library.connectLibrary('library-id'); + * penpot.library.tokensSource = source; + * // Restore local tokens: + * penpot.library.tokensSource = penpot.library.local; + * ``` + */ + tokensSource: Library; + /** * Retrieves a summary of available libraries that can be connected to. * @return Returns a promise that resolves to an array of `LibrarySummary` objects representing available libraries. @@ -4107,6 +4314,15 @@ export interface ShapeBase extends PluginData { */ resetOverrides(): void; + /** + * Applies this copy's overrides to its main component and synchronizes copies. + * Similar to the editor's "update main component" action. + * Requires `content:write` and `library:write` permissions. + * The shape must be a component copy head on the active page of the current file, + * outside any other component copy, with a main component that is not deleted. + */ + updateMain(): void; + /** * Switch a VariantComponent copy to the nearest one that has the specified property value * @param pos The position of the property to update @@ -4227,6 +4443,11 @@ export interface ShapeBase extends PluginData { * several tokens with the same name in different sets, the actual token applied * and the value set to the attributes will depend on which sets are active * (and will change if different sets or themes are activated later). + * + * NOTE that the per-side stroke width properties (`strokeWidthTop`, + * `strokeWidthRight`, `strokeWidthBottom`, `strokeWidthLeft`) are only valid on + * boards and rectangles. Applying one to any other shape raises a validation + * error. The uniform `strokeWidth` property is accepted on every shape. */ applyToken(token: Token, properties?: TokenProperty[]): void; @@ -4715,6 +4936,10 @@ export interface TokenBase { * several tokens with the same name in different sets, the actual token applied * and the value set to the attributes will depend on which sets are active * (and will change if different sets or themes are activated later). + * + * NOTE that the per-side stroke width properties are only valid on boards and + * rectangles; applying one to any other shape raises a validation error. The + * uniform `strokeWidth` property is accepted on every shape. */ applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; @@ -5372,10 +5597,12 @@ export type Token = */ export interface TokenCatalog { /** - * Returns true if the tokens can be edited (it can't - * if the tokens of the file are in an external library). - * Note that even in this case, Themes and Sets status still - * can be changed and tokens can be applied to shapes. + * Returns true if the tokens in this catalog can be edited. Only the + * catalog of the current file (`penpot.library.local.tokens`) can be + * edited, and only when the file is its own tokens source. + * When the tokens come from an external library, the local catalog + * can still change the active themes and sets, and its tokens can + * still be applied to shapes. */ readonly isEditableTokens: boolean; @@ -5449,7 +5676,8 @@ export interface TokenSet { name: string; /** - * Indicates if the set is currently active. + * Indicates if the set is active in the file that owns this catalog. + * Only sets from `penpot.library.local.tokens` can be changed. */ active: boolean; @@ -5465,6 +5693,7 @@ export interface TokenSet { /** * Toggles the active status of this set. + * Only sets from `penpot.library.local.tokens` can be changed. */ toggleActive(): void; @@ -5549,12 +5778,14 @@ export interface TokenTheme { name: string; /** - * Indicates if the theme is currently active. + * Indicates if the theme is active in the file that owns this catalog. + * Only themes from `penpot.library.local.tokens` can be changed. */ active: boolean; /** * Toggles the active status of this theme. + * Only themes from `penpot.library.local.tokens` can be changed. */ toggleActive(): void; diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index a836bdd829..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index 891d97fbaf..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } 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', ]), diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 6b84336dc3..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45" + "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61" } diff --git a/plugins/package.json b/plugins/package.json index d066b71073..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 46cce91a28..faf9371961 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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 58cb8b5a61..03d4f60051 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/Cargo.lock b/render-wasm/Cargo.lock index d1c8e874c4..2b24a8387f 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -469,6 +469,7 @@ dependencies = [ "indexmap", "insta", "macros", + "miniz_oxide", "skia-safe", "thiserror", "unicode-segmentation", diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 4ac21d18a8..4740a898bb 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -26,6 +26,7 @@ gl = "0.14.0" glam = "0.24.2" indexmap = "2.7.1" macros = { path = "macros" } +miniz_oxide = "0.8.0" skia-safe = { version = "0.153.3", default-features = false, features = [ "gl", "svg", diff --git a/render-wasm/package.json b/render-wasm/package.json index 0529bfc873..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.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45", + "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 79c219f770..9ef64d85bd 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.15.0 + version: 12.15.0 packages: - '@pnpm/exe.android-arm64@12.8.1': - resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.8.1': - resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.8.1': - resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.8.1': - resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.8.1': - resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.8.1': - resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + '@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.8.1': - resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + '@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.8.1': - resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.8.1': - resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.8.1': - resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.8.1': - resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + '@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.8.1': - resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + '@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.8.1': - resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.8.1': - resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} cpu: [x64] os: [win32] - pnpm@12.8.1: - resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} - 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.8.1': + '@pnpm/exe.android-arm64@12.15.0': optional: true - '@pnpm/exe.android-x64@12.8.1': + '@pnpm/exe.android-x64@12.15.0': optional: true - '@pnpm/exe.darwin-arm64@12.8.1': + '@pnpm/exe.darwin-arm64@12.15.0': optional: true - '@pnpm/exe.darwin-x64@12.8.1': + '@pnpm/exe.darwin-x64@12.15.0': optional: true - '@pnpm/exe.freebsd-x64@12.8.1': + '@pnpm/exe.freebsd-x64@12.15.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.8.1': + '@pnpm/exe.linux-arm64-musl@12.15.0': optional: true - '@pnpm/exe.linux-arm64@12.8.1': + '@pnpm/exe.linux-arm64@12.15.0': optional: true - '@pnpm/exe.linux-ppc64@12.8.1': + '@pnpm/exe.linux-ppc64@12.15.0': optional: true - '@pnpm/exe.linux-riscv64@12.8.1': + '@pnpm/exe.linux-riscv64@12.15.0': optional: true - '@pnpm/exe.linux-s390x@12.8.1': + '@pnpm/exe.linux-s390x@12.15.0': optional: true - '@pnpm/exe.linux-x64-musl@12.8.1': + '@pnpm/exe.linux-x64-musl@12.15.0': optional: true - '@pnpm/exe.linux-x64@12.8.1': + '@pnpm/exe.linux-x64@12.15.0': optional: true - '@pnpm/exe.win32-arm64@12.8.1': + '@pnpm/exe.win32-arm64@12.15.0': optional: true - '@pnpm/exe.win32-x64@12.8.1': + '@pnpm/exe.win32-x64@12.15.0': optional: true - pnpm@12.8.1: + pnpm@12.15.0: optionalDependencies: - '@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 + '@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/src/main.rs b/render-wasm/src/main.rs index 01a2217a07..fe38624844 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -808,6 +808,31 @@ pub extern "C" fn render_shape_pixels( }) } +#[no_mangle] +pub extern "C" fn export_target(a: u32, b: u32, c: u32, d: u32, scale: f32, format: u32) -> u32 { + let id = uuid_from_u32_quartet(a, b, c, d); + let Ok(format) = RasterFormat::from_u32(format) else { + return 1; + }; + + with_state!(state, { + let Some(shape) = state.shapes.get(&id) else { + return 1; + }; + let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, scale); + let width = (scale * extrect.width()).ceil() as i32; + let height = (scale * extrect.height()).ceil() as i32; + let max_size = get_gpu_state().max_surface_size(); + if width <= max_size && height <= max_size { + 0 + } else if format.fits_raster(width, height) { + 1 + } else { + 2 + } + }) +} + #[no_mangle] pub extern "C" fn render_stats() { get_render_state().print_stats(); @@ -820,11 +845,11 @@ pub fn free_gpu_resources() { #[no_mangle] #[wasm_error] -pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { +pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32) -> Result<*mut u8> { let id = uuid_from_u32_quartet(a, b, c, d); with_state!(state, { - let data = state.render_shape_pdf(&id, scale)?; + let data = state.render_shape_pdf(&id)?; let len = data.len() as u32; let mut buf = Vec::with_capacity(4 + data.len()); @@ -834,6 +859,69 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } +/// 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> { @@ -850,6 +938,28 @@ pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } +/// Multi-root SVG: shape UUIDs are read from the transfer buffer (16 bytes +/// each, same layout as `render_shapes_pdf`). Returns `[len u32 LE][svg bytes]`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shapes_svg(scale: f32) -> 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.rs b/render-wasm/src/render.rs index a64c618d40..5b47c8bce2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -24,6 +24,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use options::RenderOptions; +pub(crate) use strokes::per_side_stroke_outline; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; 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/pdf.rs b/render-wasm/src/render/pdf.rs index c61fef322b..746b26242d 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -1,63 +1,302 @@ +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; use super::vector; use super::RenderResources; -/// 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 +mod glyph_text; +mod to_unicode; + +/// 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, - scale: f32, ) -> Result> { - let shape = tree - .get(id) - .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?; - // Boards export at their own bounds (like the classic exporter, which - // screenshots exactly the board box): content overflowing a board lands - // outside the page/surface and is cropped. Other shapes keep the extended - // rect so their own shadows/blur are included. - let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) { - shape.selrect() - } else { - shape.extrect(tree, scale) - }; + render_to_pdf_multi(shared, &[*id], tree) +} - let page_w = bounds.width() * scale; - let page_h = bounds.height() * scale; +/// 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 mut pdf_bytes: Vec = Vec::new(); +#[cfg(test)] +mod tests { + use super::*; - let metadata = skia::pdf::Metadata { - creator: "Penpot".to_string(), - producer: "Penpot (Skia PDF)".to_string(), - ..Default::default() - }; + use crate::shapes::{Fill, Rect, SolidColor, Type}; + use crate::state::ShapesPool; + use crate::utils::uuid_from_u32_quartet; - let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata)); + use skia_safe as skia; - let mut on_page = document.begin_page((page_w, page_h), None); - - { - let page_canvas = on_page.canvas(); - page_canvas.scale((scale, scale)); - page_canvas.translate((-bounds.left(), -bounds.top())); - vector::render_tree_pdf(shared, page_canvas, id, tree, scale, 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))]); + } - Ok(pdf_bytes) + /// 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 new file mode 100644 index 0000000000..71ba721343 --- /dev/null +++ b/render-wasm/src/render/pdf/glyph_text.rs @@ -0,0 +1,297 @@ +//! Collects the text behind each drawn glyph, per font. Skia's PDF backend +//! maps substituted glyphs (ligatures, contextual alternates) to U+0000. + +use std::collections::HashMap; + +use skia_safe as skia; + +use crate::shapes::Type; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +#[derive(Debug, Clone)] +struct GlyphText { + text: String, + /// The glyph is the only one drawn for its cluster. + single_glyph: bool, + typeface: skia::typeface::TypefaceId, + /// Two typefaces sharing a font name disagree on this glyph. + conflict: bool, +} + +/// Text for each glyph, keyed by the font name Skia writes in `/BaseFont` +/// (without the subset tag). +#[derive(Debug, Default)] +pub struct GlyphTexts { + fonts: HashMap>, +} + +impl GlyphTexts { + /// Text that should replace the current mapping of `glyph` in `font`, if + /// any. `current` is the mapping Skia wrote (empty or `"\0"` when unknown). + pub fn replacement(&self, font: &str, glyph: skia::GlyphId, current: &str) -> Option<&str> { + let entry = self.fonts.get(font)?.get(&glyph)?; + if entry.conflict || entry.text.is_empty() || entry.text == current { + return None; + } + let unmapped = current.is_empty() || current == "\0"; + // A single glyph standing for several characters is a ligature: the + // font's `cmap` entry (e.g. U+FB02 for "fl") is not what was typed. + let ligature = entry.single_glyph && entry.text.chars().count() > 1; + (unmapped || ligature).then_some(entry.text.as_str()) + } + + pub fn is_empty(&self) -> bool { + self.fonts.is_empty() + } + + #[cfg(test)] + pub(super) fn with_glyph(mut self, font: &str, glyph: skia::GlyphId, text: &str) -> Self { + let value = GlyphText { + text: text.to_string(), + single_glyph: true, + typeface: 1, + conflict: false, + }; + self.insert(font, glyph, value); + self + } + + fn insert(&mut self, font: &str, glyph: skia::GlyphId, value: GlyphText) { + let glyphs = self.fonts.entry(font.to_string()).or_default(); + match glyphs.get_mut(&glyph) { + None => { + glyphs.insert(glyph, value); + } + // Same typeface, different text: keep the first one, as Skia does. + Some(existing) + if existing.typeface != value.typeface && existing.text != value.text => + { + existing.conflict = true; + } + Some(_) => {} + } + } + + /// Records the glyphs of a laid-out paragraph. `text` is the UTF-8 text the + /// paragraph was built from. + pub fn add_paragraph(&mut self, text: &str, paragraph: &mut skia::textlayout::Paragraph) { + let mut runs: Vec = Vec::new(); + paragraph.visit(|_, info| { + let Some(info) = info else { + return; + }; + let typeface = info.font().typeface(); + runs.push(Run { + typeface: typeface.unique_id(), + font_name: pdf_font_name(&typeface), + glyphs: info.glyphs().to_vec(), + starts: info.utf8_starts().iter().map(|s| *s as usize).collect(), + }); + }); + self.add_runs(text, &runs); + } + + fn add_runs(&mut self, text: &str, runs: &[Run]) { + // Cluster starts of the whole paragraph: a cluster ends where the next + // one starts, and that one may sit in another run or line. + let mut boundaries: Vec = runs + .iter() + .flat_map(|run| run.starts.iter().copied()) + .chain([text.len()]) + .collect(); + boundaries.sort_unstable(); + boundaries.dedup(); + + for run in runs { + let count = run.glyphs.len(); + for (i, &glyph) in run.glyphs.iter().enumerate() { + let start = run.starts[i]; + if i > 0 && run.starts[i - 1] == start { + continue; + } + let single_glyph = i + 1 >= count || run.starts[i + 1] != start; + if let Some(cluster) = cluster_text(text, &boundaries, start) { + self.insert( + &run.font_name, + glyph, + GlyphText { + text: cluster.to_string(), + single_glyph, + typeface: run.typeface, + conflict: false, + }, + ); + } + } + } + } +} + +struct Run { + typeface: skia::typeface::TypefaceId, + font_name: String, + glyphs: Vec, + starts: Vec, +} + +/// Text of the cluster starting at `start`. Whitespace ends it: spaces trimmed +/// at a line break are not drawn, so their start is missing from `boundaries`. +fn cluster_text<'a>(text: &'a str, boundaries: &[usize], start: usize) -> Option<&'a str> { + let end = boundaries + .iter() + .copied() + .find(|b| *b > start) + .unwrap_or(text.len()); + let cluster = text.get(start..end)?; + match cluster + .char_indices() + .skip(1) + .find(|(_, c)| c.is_whitespace()) + { + Some((at, _)) => Some(&cluster[..at]), + None => Some(cluster), + } +} + +/// The name Skia's PDF backend writes for a typeface: its PostScript name, or +/// its family name when it has none. +fn pdf_font_name(typeface: &skia::Typeface) -> String { + typeface + .post_script_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| typeface.family_name()) +} + +/// Collects the glyph texts of every visible text shape under `root`. +pub fn collect(tree: ShapesPoolRef, root: &Uuid) -> GlyphTexts { + let mut glyph_texts = GlyphTexts::default(); + let Some(root_shape) = tree.get(root) else { + return glyph_texts; + }; + for id in root_shape.all_children_iter(tree, false, true) { + let Some(shape) = tree.get(&id) else { + continue; + }; + let Type::Text(text_content) = &shape.shape_type else { + continue; + }; + if shape.hidden { + continue; + } + let text_content = text_content.new_bounds(shape.selrect()); + let width = text_content.get_width(shape.selrect().width()); + for group in text_content + .paragraph_builder_group_from_text(None) + .iter_mut() + { + for builder in group.iter_mut() { + // `build` takes the text out of the builder. + let text = builder.get_text().to_string(); + let mut paragraph = builder.build(); + paragraph.layout(width); + glyph_texts.add_paragraph(&text, &mut paragraph); + } + } + } + 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::*; + + fn run(glyphs: &[u16], starts: &[usize]) -> Run { + Run { + typeface: 1, + font_name: "Font".to_string(), + glyphs: glyphs.to_vec(), + starts: starts.to_vec(), + } + } + + #[test] + fn ligature_glyph_maps_to_its_whole_cluster() { + let mut texts = GlyphTexts::default(); + // "office": o, ffi (one glyph), c, e + texts.add_runs("office", &[run(&[10, 20, 30, 40], &[0, 1, 4, 5, 6])]); + + assert_eq!(texts.replacement("Font", 20, "\0"), Some("ffi")); + assert_eq!(texts.replacement("Font", 20, "\u{FB03}"), Some("ffi")); + assert_eq!(texts.replacement("Font", 10, "o"), None); + assert_eq!(texts.replacement("Font", 30, "\0"), Some("c")); + } + + #[test] + fn single_char_glyph_with_cmap_entry_keeps_it() { + let mut texts = GlyphTexts::default(); + texts.add_runs("a", &[run(&[5], &[0, 1])]); + + assert_eq!(texts.replacement("Font", 5, "\u{00E1}"), None); + assert_eq!(texts.replacement("Font", 5, ""), Some("a")); + } + + #[test] + fn multi_glyph_cluster_only_fills_the_first_glyph() { + let mut texts = GlyphTexts::default(); + // "é" drawn as base + mark glyphs sharing one cluster. + texts.add_runs("é", &[run(&[7, 8], &[0, 0, 2])]); + + assert_eq!(texts.replacement("Font", 7, "\0"), Some("é")); + assert_eq!(texts.replacement("Font", 8, "\0"), None); + } + + #[test] + fn clusters_end_at_starts_from_other_runs() { + let mut texts = GlyphTexts::default(); + // Right-to-left run: glyph order is the reverse of text order, and its + // last cluster ends where the next run starts. + texts.add_runs( + "abcd", + &[run(&[3, 2], &[1, 0, 0]), run(&[4, 5], &[2, 3, 4])], + ); + + assert_eq!(texts.replacement("Font", 3, "\0"), Some("b")); + assert_eq!(texts.replacement("Font", 2, "\0"), Some("a")); + } + + #[test] + fn cluster_stops_at_whitespace_dropped_at_line_breaks() { + let mut texts = GlyphTexts::default(); + // "lo world" wraps after "lo": the space at 2 is not drawn, so the + // "o" cluster would run until "w" without the whitespace stop. + texts.add_runs("lo world", &[run(&[8, 9], &[0, 1, 3])]); + + assert_eq!(texts.replacement("Font", 9, "\0"), Some("o")); + } + + #[test] + fn conflicting_typefaces_with_the_same_name_are_ignored() { + let mut texts = GlyphTexts::default(); + texts.add_runs("a", &[run(&[5], &[0, 1])]); + let mut other = run(&[5], &[0, 1]); + other.typeface = 2; + texts.add_runs("b", &[other]); + + assert_eq!(texts.replacement("Font", 5, "\0"), None); + } +} diff --git a/render-wasm/src/render/pdf/to_unicode.rs b/render-wasm/src/render/pdf/to_unicode.rs new file mode 100644 index 0000000000..ffb36f233e --- /dev/null +++ b/render-wasm/src/render/pdf/to_unicode.rs @@ -0,0 +1,793 @@ +//! Rewrites the `/ToUnicode` maps of a Skia PDF's Type0 fonts in an appended +//! incremental update, leaving the bytes Skia wrote untouched. + +use std::collections::{BTreeMap, HashMap}; + +use super::glyph_text::GlyphTexts; + +/// Glyph code to UTF-16 text. +type CMap = BTreeMap>; + +/// Returns `pdf` with fixed `/ToUnicode` maps, or unchanged when nothing needs +/// fixing or the file is not laid out as expected. +pub fn fix_to_unicode(pdf: Vec, glyph_texts: &GlyphTexts) -> Vec { + if glyph_texts.is_empty() { + return pdf; + } + match updated_cmaps(&pdf, glyph_texts) { + Some(Update { + cmaps, + xref_offset, + trailer, + }) if !cmaps.is_empty() => append_update(pdf, &cmaps, xref_offset, &trailer), + _ => pdf, + } +} + +struct Update { + /// ToUnicode object number to its new CMap. + cmaps: BTreeMap, + xref_offset: usize, + trailer: Vec, +} + +fn updated_cmaps(pdf: &[u8], glyph_texts: &GlyphTexts) -> Option { + let xref = parse_xref(pdf)?; + let mut cmaps = BTreeMap::new(); + + for &offset in xref.offsets.values() { + let Some(font) = read_object(pdf, offset) else { + continue; + }; + if dict_name(font.dict, b"/Type") != Some(b"Font".as_slice()) + || dict_name(font.dict, b"/Subtype") != Some(b"Type0".as_slice()) + { + continue; + } + let (Some(base_font), Some(to_unicode)) = ( + dict_name(font.dict, b"/BaseFont"), + dict_ref(font.dict, b"/ToUnicode"), + ) else { + continue; + }; + let font_name = strip_subset_tag(&decode_name(base_font)); + let Some(stream) = xref + .offsets + .get(&to_unicode) + .and_then(|offset| read_object(pdf, *offset)) + .and_then(|object| stream_data(&object)) + else { + continue; + }; + + let mut cmap = parse_cmap(&stream); + let mut changed = false; + for (code, value) in cmap.iter_mut() { + let current = String::from_utf16_lossy(value); + if let Some(text) = glyph_texts.replacement(&font_name, *code, ¤t) { + *value = text.encode_utf16().collect(); + changed = true; + } + } + if changed { + cmaps.insert(to_unicode, cmap); + } + } + + Some(Update { + cmaps, + xref_offset: xref.offset, + trailer: xref.trailer, + }) +} + +fn append_update( + mut pdf: Vec, + cmaps: &BTreeMap, + prev_xref: usize, + trailer: &[u8], +) -> Vec { + if pdf.last() != Some(&b'\n') { + pdf.push(b'\n'); + } + let mut offsets = Vec::with_capacity(cmaps.len()); + for (&number, cmap) in cmaps { + let data = write_cmap(cmap); + offsets.push((number, pdf.len())); + pdf.extend_from_slice( + format!("{number} 0 obj\n<> stream\n", data.len()).as_bytes(), + ); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream\nendobj\n"); + } + + let xref_offset = pdf.len(); + pdf.extend_from_slice(b"xref\n"); + for (number, offset) in offsets { + pdf.extend_from_slice(format!("{number} 1\n{offset:010} 00000 n \n").as_bytes()); + } + // Same entries as the previous trailer (`/Size`, `/Root`, `/Info`, `/ID`), + // plus `/Prev`. + let body = &trailer[..trailer.len() - 2]; + pdf.extend_from_slice(b"trailer\n"); + pdf.extend_from_slice(body); + pdf.extend_from_slice( + format!("\n/Prev {prev_xref}>>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes(), + ); + pdf +} + +// --------------------------------------------------------------------------- +// CMap + +fn parse_cmap(data: &[u8]) -> CMap { + let mut cmap = CMap::new(); + let mut tokens = CMapTokens { data, pos: 0 }; + while let Some(token) = tokens.next() { + match token { + Token::Keyword(b"beginbfchar") => { + while let Some(Token::Hex(code)) = tokens.next() { + let Some(Token::Hex(value)) = tokens.next() else { + break; + }; + if let Some(code) = to_code(&code) { + cmap.insert(code, to_utf16(&value)); + } + } + } + Token::Keyword(b"beginbfrange") => { + while let Some(Token::Hex(start)) = tokens.next() { + let (Some(Token::Hex(end)), Some(first)) = (tokens.next(), tokens.next()) + else { + break; + }; + let (Some(start), Some(end)) = (to_code(&start), to_code(&end)) else { + continue; + }; + match first { + Token::Hex(value) => { + let value = to_utf16(&value); + for (step, code) in (start..=end).enumerate() { + let mut value = value.clone(); + if let Some(last) = value.last_mut() { + *last = last.wrapping_add(step as u16); + } + cmap.insert(code, value); + } + } + Token::ArrayStart => { + let mut code = start; + while let Some(Token::Hex(value)) = tokens.next() { + cmap.insert(code, to_utf16(&value)); + code = code.wrapping_add(1); + } + } + _ => break, + } + } + } + _ => {} + } + } + cmap +} + +fn write_cmap(cmap: &CMap) -> Vec { + let mut out = String::from( + "/CIDInit /ProcSet findresource begin\n\ + 12 dict begin\n\ + begincmap\n\ + /CIDSystemInfo\n\ + << /Registry (Adobe)\n\ + /Ordering (UCS)\n\ + /Supplement 0\n\ + >> def\n\ + /CMapName /Adobe-Identity-UCS def\n\ + /CMapType 2 def\n\ + 1 begincodespacerange\n\ + <0000> \n\ + endcodespacerange\n", + ); + let entries: Vec<_> = cmap.iter().collect(); + // At most 100 entries per section (PDF 32000-1, 9.10.3). + for chunk in entries.chunks(100) { + out.push_str(&format!("{} beginbfchar\n", chunk.len())); + for (code, value) in chunk { + out.push_str(&format!("<{code:04X}> <")); + for unit in value.iter() { + out.push_str(&format!("{unit:04X}")); + } + out.push_str(">\n"); + } + out.push_str("endbfchar\n"); + } + out.push_str( + "endcmap\n\ + CMapName currentdict /CMap defineresource pop\n\ + end\n\ + end", + ); + out.into_bytes() +} + +enum Token<'a> { + Hex(Vec), + ArrayStart, + ArrayEnd, + Keyword(&'a [u8]), + Other, +} + +struct CMapTokens<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Iterator for CMapTokens<'a> { + type Item = Token<'a>; + + fn next(&mut self) -> Option> { + self.pos = skip_ws(self.data, self.pos); + let byte = *self.data.get(self.pos)?; + let start = self.pos; + self.pos += 1; + match byte { + b'<' if self.data.get(self.pos) == Some(&b'<') => { + self.pos += 1; + Some(Token::Other) + } + b'<' => { + let end = find(self.data, b">", self.pos)?; + let hex = parse_hex(&self.data[self.pos..end]); + self.pos = end + 1; + Some(Token::Hex(hex)) + } + b'[' => Some(Token::ArrayStart), + b']' => Some(Token::ArrayEnd), + _ => { + while self + .data + .get(self.pos) + .is_some_and(|b| !is_ws(*b) && !is_delimiter(*b)) + { + self.pos += 1; + } + Some(Token::Keyword(&self.data[start..self.pos])) + } + } + } +} + +fn parse_hex(hex: &[u8]) -> Vec { + let digits: Vec = hex + .iter() + .filter_map(|b| (*b as char).to_digit(16).map(|d| d as u8)) + .collect(); + digits + .chunks(2) + .map(|pair| (pair[0] << 4) | pair.get(1).copied().unwrap_or(0)) + .collect() +} + +fn to_code(bytes: &[u8]) -> Option { + match bytes { + [byte] => Some(*byte as u16), + [high, low] => Some(u16::from_be_bytes([*high, *low])), + _ => None, + } +} + +fn to_utf16(bytes: &[u8]) -> Vec { + bytes + .chunks(2) + .map(|pair| u16::from_be_bytes([pair[0], pair.get(1).copied().unwrap_or(0)])) + .collect() +} + +// --------------------------------------------------------------------------- +// PDF objects + +struct Xref { + /// Byte offset of the `xref` keyword. + offset: usize, + /// Object number to byte offset, for objects in use. + offsets: HashMap, + /// The trailer dictionary, `<<` to `>>`. + trailer: Vec, +} + +/// Reads the last xref table and its trailer. Skia writes a single classic +/// table, without xref streams. +fn parse_xref(pdf: &[u8]) -> Option { + let startxref = rfind(pdf, b"startxref")?; + let (offset, _) = parse_uint(pdf, skip_ws(pdf, startxref + b"startxref".len()))?; + parse_xref_at(pdf, offset as usize) +} + +fn parse_xref_at(pdf: &[u8], offset: usize) -> Option { + if !pdf.get(offset..)?.starts_with(b"xref") { + return None; + } + let mut offsets = HashMap::new(); + let mut pos = offset + b"xref".len(); + loop { + pos = skip_ws(pdf, pos); + if pdf.get(pos..)?.starts_with(b"trailer") { + break; + } + let (first, next) = parse_uint(pdf, pos)?; + let (count, next) = parse_uint(pdf, skip_ws(pdf, next))?; + pos = next; + for number in first..first + count { + let (entry_offset, next) = parse_uint(pdf, skip_ws(pdf, pos))?; + let (_generation, next) = parse_uint(pdf, skip_ws(pdf, next))?; + pos = skip_ws(pdf, next); + if *pdf.get(pos)? == b'n' { + offsets.insert(number as u32, entry_offset as usize); + } + pos += 1; + } + } + let start = skip_ws(pdf, pos + b"trailer".len()); + let end = skip_dict(pdf, start)?; + Some(Xref { + offset, + offsets, + trailer: pdf[start..end].to_vec(), + }) +} + +struct Object<'a> { + dict: &'a [u8], + stream: Option<&'a [u8]>, +} + +/// Reads the dictionary (and stream, if any) of the object at `offset`. +fn read_object(pdf: &[u8], offset: usize) -> Option> { + let (_, pos) = parse_uint(pdf, offset)?; + let (_, pos) = parse_uint(pdf, skip_ws(pdf, pos))?; + let pos = skip_ws(pdf, pos); + if !pdf.get(pos..)?.starts_with(b"obj") { + return None; + } + let start = skip_ws(pdf, pos + b"obj".len()); + let end = skip_dict(pdf, start)?; + let dict = &pdf[start..end]; + + let mut pos = skip_ws(pdf, end); + if !pdf.get(pos..)?.starts_with(b"stream") { + return Some(Object { dict, stream: None }); + } + pos += b"stream".len(); + if pdf.get(pos) == Some(&b'\r') { + pos += 1; + } + if pdf.get(pos) == Some(&b'\n') { + pos += 1; + } + let length = dict_uint(dict, b"/Length")? as usize; + Some(Object { + dict, + stream: Some(pdf.get(pos..pos + length)?), + }) +} + +fn stream_data(object: &Object) -> Option> { + let stream = object.stream?; + match dict_name(object.dict, b"/Filter") { + None => Some(stream.to_vec()), + Some(b"FlateDecode") => miniz_oxide::inflate::decompress_to_vec_zlib(stream).ok(), + Some(_) => None, + } +} + +/// Position right after the `>>` closing the dictionary at `pos`. +fn skip_dict(pdf: &[u8], pos: usize) -> Option { + if !pdf.get(pos..)?.starts_with(b"<<") { + return None; + } + let mut depth = 0; + let mut pos = pos; + while pos < pdf.len() { + match pdf[pos] { + b'<' if pdf.get(pos + 1) == Some(&b'<') => { + depth += 1; + pos += 2; + } + b'>' if pdf.get(pos + 1) == Some(&b'>') => { + depth -= 1; + pos += 2; + if depth == 0 { + return Some(pos); + } + } + b'<' => pos = find(pdf, b">", pos)? + 1, + b'(' => pos = skip_string(pdf, pos)?, + _ => pos += 1, + } + } + None +} + +/// Position right after the literal string starting at `pos`. +fn skip_string(pdf: &[u8], pos: usize) -> Option { + let mut depth = 0; + let mut pos = pos; + while pos < pdf.len() { + match pdf[pos] { + b'\\' => pos += 1, + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(pos + 1); + } + } + _ => {} + } + pos += 1; + } + None +} + +/// Position of the value for `key`, matching whole keys only. +fn dict_value<'a>(dict: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let mut from = 0; + while let Some(at) = find(dict, key, from) { + let end = at + key.len(); + if dict.get(end).is_some_and(|b| is_ws(*b) || is_delimiter(*b)) { + return Some(&dict[skip_ws(dict, end)..]); + } + from = end; + } + None +} + +fn dict_name<'a>(dict: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let value = dict_value(dict, key)?.strip_prefix(b"/")?; + let end = value + .iter() + .position(|b| is_ws(*b) || is_delimiter(*b)) + .unwrap_or(value.len()); + Some(&value[..end]) +} + +fn dict_uint(dict: &[u8], key: &[u8]) -> Option { + parse_uint(dict_value(dict, key)?, 0).map(|(value, _)| value) +} + +/// Object number of an indirect reference (`N 0 R`). +fn dict_ref(dict: &[u8], key: &[u8]) -> Option { + let value = dict_value(dict, key)?; + let (number, pos) = parse_uint(value, 0)?; + let (_, pos) = parse_uint(value, skip_ws(value, pos))?; + (value.get(skip_ws(value, pos)) == Some(&b'R')).then_some(number as u32) +} + +/// Decodes `#xx` escapes in a PDF name. +fn decode_name(name: &[u8]) -> String { + let mut out = Vec::with_capacity(name.len()); + let mut i = 0; + while i < name.len() { + if name[i] == b'#' && i + 2 < name.len() { + if let [byte] = parse_hex(&name[i + 1..i + 3])[..] { + out.push(byte); + i += 3; + continue; + } + } + out.push(name[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Drops the `ABCDEF+` subset tag Skia puts before every font name. +fn strip_subset_tag(name: &str) -> String { + let bytes = name.as_bytes(); + if bytes.len() > 7 && bytes[6] == b'+' && bytes[..6].iter().all(u8::is_ascii_uppercase) { + name[7..].to_string() + } else { + name.to_string() + } +} + +fn parse_uint(data: &[u8], pos: usize) -> Option<(u64, usize)> { + let digits = data + .get(pos..)? + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if digits == 0 { + return None; + } + let value = std::str::from_utf8(&data[pos..pos + digits]) + .ok()? + .parse() + .ok()?; + Some((value, pos + digits)) +} + +fn skip_ws(data: &[u8], mut pos: usize) -> usize { + while data.get(pos).is_some_and(|b| is_ws(*b)) { + pos += 1; + } + pos +} + +fn is_ws(byte: u8) -> bool { + matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | b'\x0c' | b'\0') +} + +fn is_delimiter(byte: u8) -> bool { + matches!( + byte, + b'/' | b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'{' | b'}' | b'%' + ) +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|at| at + from) +} + +fn rfind(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()) + .rposition(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + use skia_safe as skia; + + use crate::render::RenderResources; + use crate::shapes::{ + Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextContent, + TextDirection, TextSpan, Type, + }; + use crate::state::ShapesPool; + use crate::utils::uuid_from_u32_quartet; + use crate::uuid::Uuid; + + fn cmap(entries: &[(u16, &str)]) -> CMap { + entries + .iter() + .map(|(code, text)| (*code, text.encode_utf16().collect())) + .collect() + } + + /// Objects visible in the final file, following `/Prev` back to the + /// first xref table. + fn latest_offsets(pdf: &[u8]) -> HashMap { + let mut xref = parse_xref(pdf).expect("xref"); + let mut offsets = xref.offsets.clone(); + while let Some(prev) = dict_uint(&xref.trailer, b"/Prev") { + xref = parse_xref_at(pdf, prev as usize).expect("previous xref"); + for (number, offset) in &xref.offsets { + offsets.entry(*number).or_insert(*offset); + } + } + offsets + } + + fn object<'a>(pdf: &'a [u8], offsets: &HashMap, number: u32) -> Object<'a> { + read_object(pdf, offsets[&number]).expect("object") + } + + /// The Type0 font's ToUnicode map, as seen by a reader. + fn to_unicode_of_only_font(pdf: &[u8]) -> CMap { + let offsets = latest_offsets(pdf); + let fonts: Vec<_> = offsets + .values() + .filter_map(|offset| read_object(pdf, *offset)) + .filter(|o| dict_name(o.dict, b"/Subtype") == Some(b"Type0".as_slice())) + .collect(); + assert_eq!(fonts.len(), 1, "expected a single Type0 font"); + let number = dict_ref(fonts[0].dict, b"/ToUnicode").expect("ToUnicode ref"); + parse_cmap(&stream_data(&object(pdf, &offsets, number)).expect("cmap data")) + } + + /// Builds a classic PDF from object bodies numbered from 1. + fn build_pdf(objects: &[Vec]) -> Vec { + let mut pdf = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(), + ); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<>\nstartxref\n{xref}\n%%EOF", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf + } + + fn stream_object(dict: &str, data: &[u8]) -> Vec { + let mut body = format!("<<{dict}/Length {}>> stream\n", data.len()).into_bytes(); + body.extend_from_slice(data); + body.extend_from_slice(b"\nendstream"); + body + } + + #[test] + fn parses_bfchar_and_both_bfrange_forms() { + let data = b"2 beginbfchar\n<0003> <0020>\n<0332> <00660066>\nendbfchar\n\ + 2 beginbfrange\n<0010> <0012> <0041>\n<0020> <0021> [<0061> ]\nendbfrange\n"; + + assert_eq!( + parse_cmap(data), + cmap(&[ + (0x03, " "), + (0x10, "A"), + (0x11, "B"), + (0x12, "C"), + (0x20, "a"), + (0x21, "\u{1F600}"), + (0x0332, "ff"), + ]) + ); + } + + #[test] + fn written_cmap_parses_back() { + let entries: Vec<(u16, &str)> = (0..250).map(|code| (code, "x")).collect(); + let mut original = cmap(&entries); + original.insert(0x0332, "ffi".encode_utf16().collect()); + + let written = write_cmap(&original); + + assert_eq!(parse_cmap(&written), original); + assert!(String::from_utf8(written) + .unwrap() + .contains("100 beginbfchar")); + } + + #[test] + fn appends_fixed_cmap_as_incremental_update() { + let skia_cmap = write_cmap(&cmap(&[(0x03, " "), (0x0332, "\0"), (0x0335, "\u{FB02}")])); + let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&skia_cmap, 6); + let pdf = build_pdf(&[ + b"<
    >".to_vec(), + b"<>" + .to_vec(), + stream_object("/Filter /FlateDecode", &compressed), + ]); + let texts = GlyphTexts::default() + .with_glyph("My Font", 0x0332, "ffi") + .with_glyph("My Font", 0x0335, "fl") + .with_glyph("My Font", 0x03, " "); + + let fixed = fix_to_unicode(pdf.clone(), &texts); + + assert!( + fixed.starts_with(&pdf), + "original bytes must stay untouched" + ); + assert_eq!( + to_unicode_of_only_font(&fixed), + cmap(&[(0x03, " "), (0x0332, "ffi"), (0x0335, "fl")]) + ); + let trailer = parse_xref(&fixed).unwrap().trailer; + assert_eq!(dict_ref(&trailer, b"/Root"), Some(1)); + assert_eq!(dict_uint(&trailer, b"/Size"), Some(4)); + } + + #[test] + fn leaves_pdf_alone_when_nothing_changes() { + let pdf = build_pdf(&[ + b"<>".to_vec(), + b"<>".to_vec(), + stream_object("", &write_cmap(&cmap(&[(0x03, " ")]))), + ]); + let texts = GlyphTexts::default().with_glyph("Other", 0x0332, "ffi"); + + assert_eq!(fix_to_unicode(pdf.clone(), &texts), pdf); + } + + /// EB Garamond forms standard ligatures (fi, fl, ffi, ...). + const LIGATURE_FONT: &[u8] = + include_bytes!("../../../../frontend/playwright/data/render-wasm/assets/ebgaramond.ttf"); + + fn add_text(pool: &mut ShapesPool, id: Uuid, family: FontFamily, text: &str) { + let mut content = TextContent::new( + skia::Rect::from_xywh(0.0, 0.0, 600.0, 100.0), + GrowType::Fixed, + ); + let span = TextSpan::new( + text.to_string(), + family, + 40.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![Fill::Solid(SolidColor(skia::Color::BLACK))], + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span], + )); + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 600.0, 100.0); + shape.set_shape_type(Type::Text(content)); + } + + /// The text a PDF reader copies from the page: every glyph code drawn, + /// read through the font's final ToUnicode map. + fn copied_text(pdf: &[u8]) -> String { + let offsets = latest_offsets(pdf); + let page = offsets + .values() + .filter_map(|offset| read_object(pdf, *offset)) + .find(|o| dict_name(o.dict, b"/Type") == Some(b"Page".as_slice())) + .expect("page"); + let contents = dict_ref(page.dict, b"/Contents").expect("contents"); + let content = stream_data(&object(pdf, &offsets, contents)).expect("content"); + let to_unicode = to_unicode_of_only_font(pdf); + CMapTokens { + data: &content, + pos: 0, + } + .filter_map(|token| match token { + Token::Hex(codes) => Some(codes), + _ => None, + }) + .flat_map(|codes| { + codes + .chunks(2) + .filter_map(to_code) + .map(|code| String::from_utf16_lossy(&to_unicode[&code])) + .collect::>() + }) + .collect() + } + + #[test] + fn exported_ligatures_copy_back_as_typed() { + let text = "office fluffy affinity"; + let family = FontFamily::new(uuid_from_u32_quartet(0, 0, 0, 99), 400, FontStyle::Normal); + let id = uuid_from_u32_quartet(0, 0, 0, 1); + let mut pool = ShapesPool::new(); + add_text(&mut pool, id, family, text); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + resources + .fonts + .add(family, LIGATURE_FONT, false, false) + .expect("ligature font"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let pdf = super::super::render_to_pdf(&mut resources, &id, &pool).expect("pdf"); + + let trailer = parse_xref(&pdf).expect("xref").trailer; + assert!( + dict_uint(&trailer, b"/Prev").is_some(), + "the ligatures should need a ToUnicode fix" + ); + assert_eq!(copied_text(&pdf), text); + } +} diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 740d20b8d9..5bd001ac32 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -8,6 +8,16 @@ use macros::ToJs; use super::vector; use super::RenderResources; +/// Half the default 2 GiB emscripten heap (a 16384² RGBA surface), leaving +/// room for the encoded copies and the scene. Estimated, not measured. +const MAX_RASTER_BYTES: i64 = 1 << 30; +/// PNG stores each side in 31 bits; the memory budget limits it first. +const MAX_PNG_SIDE: i32 = i32::MAX; +/// libjpeg-turbo's `JPEG_MAX_DIMENSION`, below the 16-bit JPEG header limit. +const MAX_JPEG_SIDE: i32 = 65500; +/// libwebp's `WEBP_MAX_DIMENSION`: WebP stores each side in 14 bits. +const MAX_WEBP_SIDE: i32 = 16383; + /// Encoded output format for [`render_to_raster`] and `render_shape_pixels`. /// `ToJs` publishes the discriminants to `api/shared.js`, so the CLJS side /// reads them from here instead of hardcoding the codes. @@ -65,6 +75,17 @@ impl RasterFormat { pub fn clear_color(self) -> skia::Color { self.opaque_background().unwrap_or(skia::Color::TRANSPARENT) } + + pub fn fits_raster(self, width: i32, height: i32) -> bool { + let max_side = match self { + Self::Png => MAX_PNG_SIDE, + Self::Jpeg => MAX_JPEG_SIDE, + Self::Webp => MAX_WEBP_SIDE, + }; + width <= max_side + && height <= max_side + && width as i64 * height as i64 * 4 <= MAX_RASTER_BYTES + } } /// Renders a shape tree to encoded image bytes on a CPU raster surface (no @@ -79,15 +100,9 @@ pub fn render_to_raster( let Some(shape) = tree.get(id) else { return Ok((Vec::new(), 0, 0)); }; - // Boards export at their own bounds (like the classic exporter, which - // screenshots exactly the board box): content overflowing a board lands - // outside the page/surface and is cropped. Other shapes keep the extended - // rect so their own shadows/blur are included. - let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) { - shape.selrect() - } else { - shape.extrect(tree, scale) - }; + // 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, scale); let width = (bounds.width() * scale).ceil() as i32; let height = (bounds.height() * scale).ceil() as i32; diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 8a89015034..a54eac73e9 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -128,22 +128,22 @@ fn inner_corners_collapse(stroke: &Stroke, rect: &Rect, radii: &Option) .any(|(shape, (want, got))| shape.x > 0.0 && (want.x <= 0.0 || want != got)) } -/// Draws a rect/frame stroke whose sides have different widths as the area -/// between an outer and an inner (rounded) rect, mitered like CSS borders. -/// Dashed/dotted styles cut that band per side, each side fitting a whole -/// number of dashes the way a browser fits a dashed border. -#[allow(clippy::too_many_arguments)] -fn draw_per_side_stroke_on_rect( - canvas: &skia::Canvas, +struct PerSideGeometry { + band: skia::Path, + pattern: Option, + wedges: Option, + trim_to_band: bool, +} + +fn per_side_geometry( stroke: &Stroke, + style: StrokeStyle, rect: &Rect, corners: &Option, - paint: &skia::Paint, widths: [f32; 4], - scale: f32, miter: Option<[f32; 4]>, antialias: bool, -) { +) -> PerSideGeometry { // Strokes sharing a border box are shaped by the whole box: a side this one // does not paint still sets the radii its corners curve to, as in CSS. let band_widths: [f32; 4] = match miter { @@ -182,11 +182,6 @@ fn draw_per_side_stroke_on_rect( // and the whole outer rect is filled, like CSS collapsing borders. let has_hole = inner.width() > 0.0 && inner.height() > 0.0; - let mut fill_paint = paint.clone(); - fill_paint.set_style(skia::PaintStyle::Fill); - fill_paint.set_path_effect(None); - fill_paint.set_anti_alias(antialias); - let mut pb = skia::PathBuilder::new(); let (outer_radii, inner_radii) = match corners { Some(radii) => { @@ -231,7 +226,6 @@ fn draw_per_side_stroke_on_rect( // Solid is the common case and the whole band is one fill. A collapsed // band has no sides left to walk, so it fills solid too. - let style = stroke.with_width(stroke.max_width()).style_at_scale(scale); let pattern = if style == StrokeStyle::Solid || !has_hole { None } else { @@ -251,8 +245,68 @@ fn draw_per_side_stroke_on_rect( // so each one keeps only its own wedge of the corner. let wedges = miter.and_then(|profile| side_wedges(&outer, outer_radii, profile, widths)); - if pattern.is_none() && wedges.is_none() { - canvas.draw_path(&band, &fill_paint); + // Square corners need no clip: the side strips tile the band exactly. + // Rounded corners curve past them, and dots straddle the boundary. + let rounded = corners.is_some_and(|radii| radii.iter().any(|r| r.x > 0.0 || r.y > 0.0)); + let trim_to_band = rounded || style == StrokeStyle::Dotted; + + PerSideGeometry { + band, + pattern, + wedges, + trim_to_band, + } +} + +pub(crate) fn per_side_stroke_outline( + stroke: &Stroke, + rect: &Rect, + corners: &Option, + widths: [f32; 4], + miter: Option<[f32; 4]>, +) -> skia::Path { + let geometry = per_side_geometry(stroke, stroke.style, rect, corners, widths, miter, true); + let outline = match geometry.pattern { + Some(pattern) if geometry.trim_to_band => pattern + .op(&geometry.band, skia::PathOp::Intersect) + .unwrap_or(pattern), + Some(pattern) => pattern, + None => geometry.band, + }; + match &geometry.wedges { + Some(wedges) => outline + .op(wedges, skia::PathOp::Intersect) + .unwrap_or(outline), + None => outline, + } +} + +/// Draws a rect/frame stroke whose sides have different widths as the area +/// between an outer and an inner (rounded) rect, mitered like CSS borders. +/// Dashed/dotted styles cut that band per side, each side fitting a whole +/// number of dashes the way a browser fits a dashed border. +#[allow(clippy::too_many_arguments)] +fn draw_per_side_stroke_on_rect( + canvas: &skia::Canvas, + stroke: &Stroke, + rect: &Rect, + corners: &Option, + paint: &skia::Paint, + widths: [f32; 4], + scale: f32, + miter: Option<[f32; 4]>, + antialias: bool, +) { + let mut fill_paint = paint.clone(); + fill_paint.set_style(skia::PaintStyle::Fill); + fill_paint.set_path_effect(None); + fill_paint.set_anti_alias(antialias); + + let style = stroke.with_width(stroke.max_width()).style_at_scale(scale); + let geometry = per_side_geometry(stroke, style, rect, corners, widths, miter, antialias); + + if geometry.pattern.is_none() && geometry.wedges.is_none() { + canvas.draw_path(&geometry.band, &fill_paint); return; } @@ -271,22 +325,19 @@ fn draw_per_side_stroke_on_rect( } } - if let Some(wedges) = &wedges { + if let Some(wedges) = &geometry.wedges { canvas.clip_path(wedges, skia::ClipOp::Intersect, antialias); } - match &pattern { + match &geometry.pattern { Some(pattern) => { - // Square corners need no clip: the side strips tile the band exactly. - // Rounded corners curve past them, and dots straddle the boundary. - let rounded = corners.is_some_and(|radii| radii.iter().any(|r| r.x > 0.0 || r.y > 0.0)); - if rounded || style == StrokeStyle::Dotted { - canvas.clip_path(&band, skia::ClipOp::Intersect, antialias); + if geometry.trim_to_band { + canvas.clip_path(&geometry.band, skia::ClipOp::Intersect, antialias); } canvas.draw_path(pattern, &fill_paint); } None => { - canvas.draw_path(&band, &fill_paint); + canvas.draw_path(&geometry.band, &fill_paint); } } canvas.restore(); @@ -1538,3 +1589,51 @@ mod per_side_tests { assert!(!pattern.contains((227.0, 50.0)), "right gap was filled"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn per_side_stroke_is_an_evenodd_band() { + let stroke = Stroke::new_center_stroke(2., StrokeStyle::Solid, None, None, None, None); + let rect = Rect::from_xywh(0., 0., 100., 50.); + let path = per_side_stroke_outline(&stroke, &rect, &None, [2., 4., 6., 8.], None); + + assert_eq!(path.fill_type(), skia::PathFillType::EvenOdd); + assert_eq!( + path.compute_tight_bounds(), + Rect::from_ltrb(-4., -1., 102., 53.) + ); + assert!(!path.contains(Point::new(50., 25.))); + assert!(path.contains(Point::new(-2., 25.))); + } + + #[test] + fn per_side_dashed_stroke_keeps_its_gaps() { + let stroke = Stroke::new_center_stroke(4., StrokeStyle::Dashed, None, None, None, None); + let rect = Rect::from_xywh(0., 0., 200., 100.); + let path = per_side_stroke_outline(&stroke, &rect, &None, [4., 4., 4., 8.], None); + + let hits = (10..190) + .filter(|x| path.contains(Point::new(*x as f32, 0.))) + .count(); + assert!(hits > 0 && hits < 180, "hits: {hits}"); + } + + #[test] + fn per_side_stroke_miters_against_its_siblings() { + let stroke = Stroke::new_center_stroke(4., StrokeStyle::Solid, None, None, None, None); + let rect = Rect::from_xywh(0., 0., 100., 50.); + let path = per_side_stroke_outline( + &stroke, + &rect, + &None, + [4., 0., 0., 0.], + Some([4., 4., 0., 0.]), + ); + + assert!(path.contains(Point::new(101., -1.5))); + assert!(!path.contains(Point::new(99., 1.5))); + } +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index db7759102b..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"; @@ -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); @@ -591,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/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap index feb3c531dd..1127c671f0 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap @@ -3,6 +3,6 @@ source: src/render/svg/tests.rs expression: svg --- - - + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 7557eebb7c..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(") -> ShapesPool { } fn raster_png(pool: &ShapesPool, id: Uuid) -> Vec { + let (bytes, _, _) = raster_png_size(pool, id); + bytes +} + +fn raster_png_size(pool: &ShapesPool, id: Uuid) -> (Vec, i32, i32) { let mut resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); - let (bytes, _, _) = crate::render::raster::render_to_raster( + crate::render::raster::render_to_raster( &mut resources, &id, pool, 1.0, crate::render::raster::RasterFormat::Png, ) - .expect("raster export"); - bytes + .expect("raster export") +} + +/// #12388: multi-element (headless) raster must include a board's own drop +/// shadow, matching single-export sizing (spread 4 → +4px each side). +#[test] +fn raster_export_of_board_includes_own_drop_shadow_spread() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_shadow(Shadow::new( + skia::Color::from_rgb(0, 200, 0), + 0.0, + 4.0, + (0.0, 0.0), + ShadowStyle::Drop, + false, + )); + } + + let (_, width, height) = raster_png_size(&pool, frame_id); + assert_eq!( + (width, height), + (208, 128), + "board raster must grow by drop-shadow spread, not stay at selrect" + ); +} + +/// Overflowing children stay cropped; only the board's own effects expand +/// the headless raster page (classic exporter board-box rule). +#[test] +fn raster_export_of_board_crops_overflowing_child_but_keeps_own_shadow() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + let child = uid(2); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_shadow(Shadow::new( + skia::Color::from_rgb(0, 200, 0), + 0.0, + 4.0, + (0.0, 0.0), + ShadowStyle::Drop, + false, + )); + frame.add_child(child); + } + add_solid_rect( + &mut pool, + child, + frame_id, + (150.0, 80.0, 280.0, 200.0), + skia::Color::from_rgb(255, 0, 0), + ); + + let (_, width, height) = raster_png_size(&pool, frame_id); + assert_eq!( + (width, height), + (208, 128), + "overflowing child must not grow board raster; own shadow still must" + ); } #[test] @@ -3714,3 +3909,73 @@ fn raster_export_of_child_paints_inherited_group_fill() { assert_eq!(inherited_png, raster_png(&own, uid(2))); assert_ne!(inherited_png, raster_png(&bare, uid(2))); } + +/// Reads one pixel of an encoded raster export as unpremultiplied RGBA. +fn raster_pixel(png: &[u8], (x, y): (i32, i32)) -> skia::Color { + let image = skia::Image::from_encoded(skia::Data::new_copy(png)).expect("decode png"); + let info = skia::ImageInfo::new( + (1, 1), + skia::ColorType::RGBA8888, + skia::AlphaType::Unpremul, + None, + ); + let mut px = [0u8; 4]; + assert!(image.read_pixels( + &info, + &mut px, + 4, + (x, y), + skia::image::CachingHint::Disallow + )); + skia::Color::from_argb(px[3], px[0], px[1], px[2]) +} + +/// #12334: headless raster/PDF must draw a masked group's inner shadow +/// inside the mask outline, as the GPU single export does. +#[test] +fn raster_export_of_masked_group_draws_inner_shadow() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_id = uid(2); + let content_id = uid(3); + let blue = skia::Color::from_rgb(0, 63, 255); + + add_masked_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + &[mask_id, content_id], + ); + add_solid_circle( + &mut pool, + mask_id, + group_id, + (0.0, 0.0, 100.0, 100.0), + skia::Color::BLACK, + ); + add_solid_rect( + &mut pool, + content_id, + group_id, + (0.0, 0.0, 100.0, 100.0), + blue, + ); + pool.get_mut(&group_id).unwrap().add_shadow(Shadow::new( + skia::Color::BLACK, + 0.0, + 0.0, + (20.0, 0.0), + ShadowStyle::Inner, + false, + )); + + let (png, width, height) = raster_png_size(&pool, group_id); + assert_eq!((width, height), (100, 100)); + + // Left edge: the shifted silhouette leaves a band the shadow fills. + assert_eq!(raster_pixel(&png, (8, 50)), skia::Color::BLACK); + assert_eq!(raster_pixel(&png, (50, 50)), blue); + // Outside the circle the mask still clears everything. + assert_eq!(raster_pixel(&png, (3, 3)).a(), 0); +} diff --git a/render-wasm/src/render/svg/text.rs b/render-wasm/src/render/svg/text.rs index 430e6155e6..13a58ee19d 100644 --- a/render-wasm/src/render/svg/text.rs +++ b/render-wasm/src/render/svg/text.rs @@ -137,7 +137,7 @@ fn emit_image_text_stroke( let href = xml_escape_attr(url); // GPU text strokes paint the image via `get_fill_shader` onto `selrect` // with cover/center scaling — not `selrect + stroke.delta()` stretched. - let dest = text_image_cover_dest(&element.selrect(), image_fill); + let dest = image_fill.cover_rect(&element.selrect()); emit_linked_image_element(builder, image_fill, dest, &href, None, true, *matrix); builder.close_group(); @@ -150,20 +150,6 @@ fn emit_image_text_stroke( Ok(()) } -/// Cover-centered dest matching [`crate::shapes::get_fill_shader`] for images. -fn text_image_cover_dest(selrect: &skia_safe::Rect, image_fill: &ImageFill) -> 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/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/render/vector.rs b/render-wasm/src/render/vector.rs index 15a8dc0c32..bfbeedf0d0 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -474,10 +474,9 @@ pub(super) fn render_tree_pdf( canvas: &Canvas, id: &Uuid, tree: ShapesPoolRef, - scale: f32, page: skia::Rect, ) -> Result<()> { - render_tree_dispatch(shared, canvas, id, tree, scale, page, true) + render_tree_dispatch(shared, canvas, id, tree, 1.0, page, true) } #[allow(clippy::too_many_arguments)] @@ -822,34 +821,22 @@ fn render_group( let child_opts = opts.for_children_of(element); if masked { - // Mirror the GPU mask: render all children (including the mask shape) - // as content, then re-draw the mask silhouette (the group's first child) - // with DstIn to clip everything to it. - let paint = Paint::default(); - let subtree_bounds = element.extrect(tree, scale); - canvas.save_layer( - &skia::canvas::SaveLayerRec::default() - .bounds(&subtree_bounds) - .paint(&paint), - ); - - for child_id in &children { - render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; - } - - if let Some(mask_id) = element.mask_id() { - let mut mask_paint = Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); + // Inner shadows filter the masked pixels, so they follow the mask's + // outline. The canvas matrix already carries `scale`. + let inner_shadows = element.masked_group_inner_shadow_filter(); + if let Some(filter) = &inner_shadows { + let mut paint = Paint::default(); + paint.set_image_filter(filter.clone()); canvas.save_layer( &skia::canvas::SaveLayerRec::default() - .bounds(&subtree_bounds) - .paint(&mask_paint), + .bounds(&element.extrect(tree, scale)) + .paint(&paint), ); - render_tree_inner(shared, canvas, mask_id, tree, scale, &child_opts)?; - canvas.restore(); // mask layer } - - canvas.restore(); // composition layer + render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; + if inner_shadows.is_some() { + canvas.restore(); // inner shadow layer + } } else { for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; @@ -863,6 +850,42 @@ fn render_group( Ok(()) } +fn render_masked_children( + shared: &mut RenderResources, + canvas: &Canvas, + element: &Shape, + children: &[Uuid], + tree: ShapesPoolRef, + scale: f32, + child_opts: &TreeOpts, +) -> Result<()> { + let subtree_bounds = element.extrect(tree, scale); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&Paint::default()), + ); + + for child_id in children { + render_tree_inner(shared, canvas, child_id, tree, scale, child_opts)?; + } + + if let Some(mask_id) = element.mask_id() { + let mut mask_paint = Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&mask_paint), + ); + render_tree_inner(shared, canvas, mask_id, tree, scale, child_opts)?; + canvas.restore(); // mask layer + } + + canvas.restore(); // composition layer + Ok(()) +} + // --------------------------------------------------------------------------- // Frames // --------------------------------------------------------------------------- @@ -991,8 +1014,12 @@ fn render_container_drop_shadows( let children = children_paint_order(tree, element); let child_opts = opts.for_children_of(element); - for child_id in &children { - render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; + if element.is_masked_group() { + render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; + } else { + for child_id in &children { + render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; + } } canvas.restore(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 940e3d724b..3baa6dd66c 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -416,6 +416,7 @@ impl Shape { pub fn set_clip(&mut self, value: bool) { self.clip_content = value; + self.invalidate_extrect(); } pub fn set_rotation(&mut self, angle: f32) { @@ -1004,16 +1005,14 @@ impl Shape { let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open()); let mut rect = bounds.to_rect(); - for shadow in self.shadows_visible() { - if !shadow.hidden() { - if let Some(filter) = shadow.get_drop_shadow_filter() { - let mut source = rect; - if mitered && shadow.spread > 0.0 { - let tip = (MITER_LIMIT - 1.0) * (max_stroke + shadow.spread); - source.outset((tip, tip)); - } - rect.join(filter.compute_fast_bounds(source)); + for shadow in self.drop_shadows_visible() { + if let Some(filter) = shadow.get_drop_shadow_filter() { + let mut source = rect; + if mitered && shadow.spread > 0.0 { + let tip = (MITER_LIMIT - 1.0) * (max_stroke + shadow.spread); + source.outset((tip, tip)); } + rect.join(filter.compute_fast_bounds(source)); } } Bounds::from_rect(&rect) @@ -1147,10 +1146,28 @@ impl Shape { extrect } - fn own_extrect_bounds(&self) -> Bounds { + pub(crate) fn own_extrect_bounds(&self) -> Bounds { self.expand_own_bounds(self.own_base_bounds(), true) } + /// Bounds for a headless export rooted at this shape. + /// + /// Frames keep the classic board-box crop of overflowing children, but + /// still expand by their own strokes / drop shadows / layer blur so those + /// effects are not clipped (parity with single-export `extrect` for the + /// board itself). Other shapes use full [`Self::extrect`]. + pub fn export_root_bounds(&self, tree: ShapesPoolRef, scale: f32) -> math::Rect { + if matches!(self.shape_type, Type::Frame(_)) { + let mut bounds = self.own_extrect_bounds(); + if !self.transform.is_identity() { + bounds.transform_mut(&self.centered_transform()); + } + bounds.to_rect() + } else { + self.extrect(tree, scale) + } + } + /// The shape's own geometry bounds, before stroke/shadow/blur margins. fn own_base_bounds(&self) -> Bounds { let shape = self; @@ -1495,10 +1512,6 @@ impl Shape { .filter(|shadow| shadow.style() == ShadowStyle::Inner && !shadow.hidden()) } - pub fn shadows_visible(&self) -> impl DoubleEndedIterator { - self.shadows.iter().rev().filter(|shadow| !shadow.hidden()) - } - pub fn to_path_transform(&self) -> Option { match self.shape_type { Type::Path(_) | Type::Bool(_) => { @@ -1985,6 +1998,25 @@ impl Shape { }) } + /// Source graphic with every visible inner shadow of the masked group over + /// it, for the CPU export renderer; `None` without inner shadows. + pub fn masked_group_inner_shadow_filter(&self) -> Option { + if !self.is_masked_group() { + return None; + } + + let mut layers: Vec> = vec![None]; + for shadow in self.inner_shadows_visible() { + layers.push(shadow.get_inner_shadow_filter()); + } + + if layers.len() > 1 { + skia::image_filters::merge(layers, None) + } else { + None + } + } + /// Image filter for the `save_layer` that wraps a masked group, i.e. for /// the result *after* the mask has been composited into it. /// @@ -2377,6 +2409,28 @@ mod tests { math::Rect::from_ltrb(0.0, 0.0, 100.0, 100.0) } + #[test] + fn masked_group_inner_shadow_filter_needs_an_inner_shadow() { + let drop_only = masked_group_with(vec![drop_shadow(4.0, 0.0, (10.0, 10.0))], None); + assert!(drop_only.masked_group_inner_shadow_filter().is_none()); + + let inner = masked_group_with(vec![inner_shadow(4.0, (10.0, 10.0))], None); + assert!(inner.masked_group_inner_shadow_filter().is_some()); + + let mut unmasked = inner.clone(); + unmasked.set_masked(false); + assert!(unmasked.masked_group_inner_shadow_filter().is_none()); + } + + #[test] + fn masked_group_inner_shadow_filter_keeps_imperceptible_shadows() { + // Too small for the workspace filter at 1x, but export draws it. + let shape = masked_group_with(vec![inner_shadow(1.0, (0.0, 0.0))], None); + + assert!(shape.masked_group_layer_filter(1.0, false, false).is_none()); + assert!(shape.masked_group_inner_shadow_filter().is_some()); + } + #[test] fn masked_group_layer_filter_is_none_without_effects() { let shape = masked_group_with(vec![], None); @@ -2552,6 +2606,35 @@ mod tests { assert!(extrect.bottom > 50.0 + 20.0); } + /// Inner shadows are drawn inside the shape; they must not inflate export + /// size (WebGL PNG surface uses `extrect`). Regression for #12386. + #[test] + fn leaf_extrect_ignores_inner_shadow_offset() { + let mut pool = ShapesPool::new(); + pool.initialize(1); + + let shape_id = Uuid::new_v4(); + { + let shape = pool.add_shape(shape_id); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(0.0, 0.0, 200.0, 200.0); + shape.add_shadow(inner_shadow(0.0, (0.0, 1.0))); + } + + let shape = pool.get(&shape_id).expect("shape should exist"); + let baseline = { + let mut no_shadow = shape.clone(); + no_shadow.clear_shadows(); + no_shadow.calculate_extrect(&pool, 1.0) + }; + let extrect = shape.calculate_extrect(&pool, 1.0); + + assert_eq!(extrect.height(), baseline.height()); + assert_eq!(extrect.width(), baseline.width()); + assert_eq!(extrect.height(), 200.0); + assert_eq!(extrect.width(), 200.0); + } + #[test] fn test_apply_transform() { let mut shape = Shape::new(Uuid::new_v4()); 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/shadows.rs b/render-wasm/src/shapes/shadows.rs index 11a0e13295..b13c6d76bf 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -87,6 +87,10 @@ impl Shadow { } pub fn get_drop_shadow_filter(&self) -> Option { + if self.style == ShadowStyle::Inner { + return None; + } + let sigma = radius_to_sigma(self.blur); let mut filter = image_filters::drop_shadow_only( (self.offset.0, self.offset.1), diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 9a9212ddd2..a8328be0d1 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, } } @@ -878,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, ) } @@ -900,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(); @@ -915,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, @@ -924,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); } @@ -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/state.rs b/render-wasm/src/state.rs index 42653f876a..97296058b0 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, } } @@ -99,14 +102,38 @@ impl State { get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp, format) } - pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result> { - crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale) + pub fn render_shape_pdf(&mut self, id: &Uuid) -> Result> { + 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) } + pub fn render_shapes_svg(&mut self, ids: &[Uuid], scale: f32) -> 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( diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index be0a34e8eb..c877399250 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -144,6 +144,10 @@ impl ShapesPoolImpl { pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { let idx = *self.uuid_to_idx.get(id)?; + // The caller may change the shape, so its modified copy goes stale. + if let Some(cell) = self.modified_shape_cache.get_mut(&idx) { + *cell = OnceCell::new(); + } Some(&mut self.shapes[idx]) } @@ -577,3 +581,22 @@ impl Clone for ShapesPoolImpl { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_mut_refreshes_the_modified_shape() { + let mut pool = ShapesPool::new(); + pool.initialize(1); + let id = Uuid::new_v4(); + pool.add_shape(id).set_opacity(1.0); + + pool.set_modifiers(HashMap::from([(id, skia::Matrix::translate((10.0, 0.0)))])); + assert_eq!(pool.get(&id).expect("shape").opacity, 1.0); + + pool.get_mut(&id).expect("shape").set_opacity(0.5); + assert_eq!(pool.get(&id).expect("shape").opacity, 0.5); + } +} diff --git a/render-wasm/src/state/ui.rs b/render-wasm/src/state/ui.rs index d29645cc02..6878875c5e 100644 --- a/render-wasm/src/state/ui.rs +++ b/render-wasm/src/state/ui.rs @@ -36,8 +36,11 @@ impl GuidePool { } let world_tolerance = tolerance / zoom; - let vertical = Self::find_closest_in_axis(&self.vertical, x, world_tolerance); - let horizontal = Self::find_closest_in_axis(&self.horizontal, y, world_tolerance); + // Vertical guides run along y; horizontal along x. Pass the along-line + // coordinate so board-bound guides are only hittable inside their + // frame_range (matching the solid drawn segment). + let vertical = Self::find_closest_in_axis(&self.vertical, x, y, world_tolerance); + let horizontal = Self::find_closest_in_axis(&self.horizontal, y, x, world_tolerance); match (vertical, horizontal) { (Some(v), Some(h)) => { @@ -53,7 +56,12 @@ impl GuidePool { } } - fn find_closest_in_axis(guides: &[Guide], coord: f32, world_tolerance: f32) -> Option<&Guide> { + fn find_closest_in_axis( + guides: &[Guide], + coord: f32, + along: f32, + world_tolerance: f32, + ) -> Option<&Guide> { if guides.is_empty() { return None; } @@ -66,6 +74,9 @@ impl GuidePool { for candidate_idx in [idx.wrapping_sub(1), idx] { if candidate_idx < guides.len() { let guide = &guides[candidate_idx]; + if !guide.contains_along(along) { + continue; + } let dist = (guide.position() - coord).abs(); if dist <= world_tolerance && dist <= closest_dist { closest_dist = dist; @@ -183,6 +194,24 @@ mod tests { ) } + fn vertical_board_guide(x: f32, y0: f32, y1: f32, index: usize) -> Guide { + Guide::new( + GuideKind::Vertical(x), + Color::BLACK, + Some(index), + Some((y0, y1)), + ) + } + + fn horizontal_board_guide(y: f32, x0: f32, x1: f32, index: usize) -> Guide { + Guide::new( + GuideKind::Horizontal(y), + Color::BLACK, + Some(index), + Some((x0, x1)), + ) + } + fn pool_with(guides: Vec) -> GuidePool { let mut pool = GuidePool::new(); pool.set(guides); @@ -264,4 +293,49 @@ mod tests { assert!(pool.find_at(104.0, 0.0, 2.0, 8.0).is_some()); assert!(pool.find_at(105.0, 0.0, 2.0, 8.0).is_none()); } + + #[test] + fn find_at_hits_vertical_board_guide_inside_frame_range() { + let pool = pool_with(vec![vertical_board_guide(100.0, 0.0, 200.0, 0)]); + let guide = pool.find_at(102.0, 50.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 0); + } + + #[test] + fn find_at_misses_vertical_board_guide_outside_frame_range() { + let pool = pool_with(vec![vertical_board_guide(100.0, 0.0, 200.0, 0)]); + assert!(pool.find_at(102.0, 250.0, 1.0, 8.0).is_none()); + } + + #[test] + fn find_at_hits_horizontal_board_guide_inside_frame_range() { + let pool = pool_with(vec![horizontal_board_guide(200.0, 0.0, 300.0, 1)]); + let guide = pool.find_at(50.0, 203.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 1); + } + + #[test] + fn find_at_misses_horizontal_board_guide_outside_frame_range() { + let pool = pool_with(vec![horizontal_board_guide(200.0, 0.0, 300.0, 1)]); + assert!(pool.find_at(350.0, 203.0, 1.0, 8.0).is_none()); + } + + #[test] + fn find_at_free_guide_still_hits_outside_any_board() { + let pool = pool_with(vec![vertical_guide(100.0, 0)]); + let guide = pool.find_at(102.0, 999.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 0); + } + + #[test] + fn find_at_misses_hidden_board_guide_empty_range() { + // Serializer uses [Infinity, Infinity] for rotated / non-root boards. + let pool = pool_with(vec![vertical_board_guide( + 100.0, + f32::INFINITY, + f32::INFINITY, + 0, + )]); + assert!(pool.find_at(100.0, 50.0, 1.0, 8.0).is_none()); + } } diff --git a/render-wasm/src/ui.rs b/render-wasm/src/ui.rs index d0e47c18e7..c7dfe0d8af 100644 --- a/render-wasm/src/ui.rs +++ b/render-wasm/src/ui.rs @@ -55,4 +55,22 @@ impl Guide { pub fn position(&self) -> f32 { self.kind.value() } + + /// Whether `along` (the coordinate along the guide line: y for vertical + /// guides, x for horizontal) falls inside this guide's interactive + /// segment. Free guides (`frame_range = None`) accept any value; board- + /// bound guides only accept points inside their clipped range. + pub fn contains_along(&self, along: f32) -> bool { + match self.frame_range { + None => true, + Some((start, end)) => { + let (lo, hi) = if start <= end { + (start, end) + } else { + (end, start) + }; + along >= lo && along <= hi + } + } + } } diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 86c9a12abe..301e5cfd1a 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,30 +1,67 @@ use crate::error::{Error, Result}; use crate::get_resources; use crate::mem; -use crate::shapes::Fill; +use crate::shapes::{Fill, Shape, TextContent, Type}; use crate::state::State; use crate::uuid::Uuid; use crate::with_state; use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet}; use macros::wasm_error; +fn text_content_uses_image(content: &TextContent, image_id: Uuid) -> bool { + content.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + span.fills + .iter() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + }) + }) +} + +fn shape_uses_image(shape: &Shape, image_id: Uuid) -> bool { + if shape + .fills() + .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) + || shape + .strokes + .iter() + .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) + { + return true; + } + match &shape.shape_type { + Type::Text(content) => text_content_uses_image(content, image_id), + _ => false, + } +} + +fn rebuild_text_layout_for_image(shape: &mut Shape) { + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + // Image shaders are baked into Skia paragraphs at layout time. When the + // media arrives after the first layout, force a rebuild so text fills + // pick up `get_image` (rect fills look up the image every paint). + text_content.force_next_layout_update(); + text_content.update_layout(shape.selrect); + shape.apply_deferred_batch_paint(); + shape.invalidate_extrect(); +} + fn touch_shapes_with_image(state: &mut State, image_id: Uuid) { - let ids: Vec = state + let ids: Vec<(Uuid, bool)> = state .shapes .iter() - .filter(|shape| { - shape - .fills() - .any(|f| matches!(f, Fill::Image(i) if i.id() == image_id)) - || shape - .strokes - .iter() - .any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id)) - }) - .map(|shape| shape.id) + .filter(|shape| shape_uses_image(shape, image_id)) + .map(|shape| (shape.id, matches!(shape.shape_type, Type::Text(_)))) .collect(); - for id in ids { + for (id, is_text) in ids { + if is_text { + if let Some(shape) = state.shapes.get_mut(&id) { + rebuild_text_layout_for_image(shape); + } + } state.touch_shape(id); } } diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index b035ee4f81..28b2193964 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -5,7 +5,8 @@ use std::mem::size_of; use std::sync::{Mutex, OnceLock}; use crate::error::{Error, Result}; -use crate::shapes::{stroke_to_path, Path, Segment, ToPath}; +use crate::render::per_side_stroke_outline; +use crate::shapes::{stroke_to_path, Path, Segment, Stroke, ToPath, Type}; use crate::{mem, with_current_shape, with_current_shape_mut}; const RAW_SEGMENT_DATA_SIZE: usize = size_of::(); @@ -247,17 +248,34 @@ pub extern "C" fn convert_stroke_to_path(stroke_index: i32) -> *mut u8 { with_current_shape!(state, |shape: &Shape| { let idx = stroke_index as usize; if let Some(stroke) = shape.strokes.get(idx) { - let shape_path = shape.to_path(&state.shapes); - let path_transform = shape.to_path_transform(); + let per_side = stroke + .per_side_widths() + .filter(|_| matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))); - if let Some(path) = stroke_to_path( - stroke, - &shape_path, - path_transform.as_ref(), - &shape.selrect, - shape.svg_attrs.as_ref(), - false, - ) { + let path = match per_side { + Some(widths) => { + let outline = per_side_stroke_outline( + stroke, + &shape.selrect, + &shape.shape_type.corners(), + widths, + Stroke::per_side_profile(shape.visible_strokes()), + ); + Some(Path::from_skia_path_accurate( + outline.make_transform(&shape.centered_transform()), + )) + } + None => stroke_to_path( + stroke, + &shape.to_path(&state.shapes), + shape.to_path_transform().as_ref(), + &shape.selrect, + shape.svg_attrs.as_ref(), + false, + ), + }; + + if let Some(path) = path { even_odd = path.is_even_odd(); result = path .segments() 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) + }) +} diff --git a/scripts/check-commit b/scripts/check-commit index 3f2792552e..df4672ea59 100755 --- a/scripts/check-commit +++ b/scripts/check-commit @@ -5,7 +5,7 @@ Check commit messages against Penpot's commit guidelines. Validates commit messages using the rules defined in: - .github/workflows/commit-checker.yml (regex pattern) - CONTRIBUTING.md (formatting rules, subject length, DCO) - - .serena/memories/workflow/creating-commits.md (body wrapped at 76 chars) + - .serena/memories/workflow/creating-commits.md (body wrapped at 80 chars) By default, checks HEAD. Use --commit to specify a different commit. @@ -39,18 +39,25 @@ COMMIT_PATTERN = re.compile( MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") +# ── Subject line length ────────────────────────────────────────────────────── +# Subject lines must fit in 80 characters. Merge/Revert/Reapply commits are +# exempt: git prepends "Merge ..."/"Revert ..." to an existing subject, so +# enforcing the limit would fail on text the author did not write. +MAX_SUBJECT_LINE = 80 + # ── Body line wrapping ─────────────────────────────────────────────────────── -# Commit bodies must wrap at 76 characters (see -# .serena/memories/workflow/creating-commits.md). That leaves room for the -# four-space indent git log adds, fitting an 80-column terminal. Trailers and +# Commit bodies must wrap at 80 characters (see +# .serena/memories/workflow/creating-commits.md). Trailers and # URLs are exempt: they cannot be wrapped without losing meaning. -MAX_BODY_LINE = 76 +MAX_BODY_LINE = 80 TRAILER_PATTERN = re.compile( - r"^(Signed-off-by|Co-authored-by|Co-developed-by|Reviewed-by|" + r"^(Signed-off-by|Co-developed-by|Reviewed-by|" r"Acked-by|Tested-by|Reported-by|Suggested-by|AI-assisted-by):" ) +FORBIDDEN_TRAILER_PATTERN = re.compile(r"^\s*Co-authored-by\s*:", re.IGNORECASE) + URL_PATTERN = re.compile(r"https?://\S+") # ═══════════════════════════════════════════════════════════════════════════════ @@ -108,11 +115,13 @@ def check_regex(message): def check_subject_length(message): - """Subject line must be ≤ 70 characters.""" + """Subject line must be ≤ 80 characters (Merge/Revert/Reapply exempt).""" first_line = message.split("\n")[0] - if len(first_line) > 70: + if MERGE_PATTERN.match(first_line): + return True, None + if len(first_line) > MAX_SUBJECT_LINE: return False, ( - f"Subject line exceeds 70 characters ({len(first_line)} chars):\n" + f"Subject line exceeds {MAX_SUBJECT_LINE} characters ({len(first_line)} chars):\n" f" {first_line}" ) return True, None @@ -164,7 +173,7 @@ def check_body_blank_line(message): def check_body_line_length(message): - """Body lines must wrap at 76 characters or fewer. + """Body lines must wrap at 80 characters or fewer. The subject (first line) has its own length rule. Trailers (e.g. Signed-off-by) and lines carrying a URL are exempt, since wrapping them @@ -209,6 +218,25 @@ def check_signed_off_by(message): return True, None +def check_no_co_authored_by(message): + """Check that the forbidden Co-authored-by trailer is absent.""" + offenders = [ + (line_number, line) + for line_number, line in enumerate(message.split("\n"), start=1) + if FORBIDDEN_TRAILER_PATTERN.match(line) + ] + if not offenders: + return True, None + details = "\n".join( + f" line {line_number}: {line!r}" + for line_number, line in offenders + ) + return False, ( + "The 'Co-authored-by:' trailer is forbidden and must not appear " + "in the commit message. Offending line(s):\n" + details + ) + + # ═══════════════════════════════════════════════════════════════════════════════ def main(): @@ -229,11 +257,12 @@ def main(): validators = [ ("Regex pattern", check_regex), - ("Subject ≤ 70 chars", check_subject_length), + ("Subject ≤ 80 chars", check_subject_length), ("No trailing period in subject", check_subject_no_trailing_dot), ("Subject capitalized", check_subject_capitalized), ("Blank line after subject", check_body_blank_line), - ("Body wrapped at 76 chars", check_body_line_length), + ("Body wrapped at 80 chars", check_body_line_length), + ("No Co-authored-by trailer", check_no_co_authored_by), ] all_ok = True diff --git a/scripts/setup-opencode b/scripts/setup-opencode new file mode 100755 index 0000000000..044311b45f --- /dev/null +++ b/scripts/setup-opencode @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Install the opencode CLI as a global pnpm package. +# +# opencode is not part of the devenv image: run this when you want the +# `opencode` and `opencode2` commands. The install lives in $PNPM_HOME, which +# inside the devenv is the penpot home (the user-data volume), so it survives +# a container re-creation. +# +# Usage: scripts/setup-opencode + +set -euo pipefail + +if ! command -v pnpm >/dev/null 2>&1; then + echo "setup-opencode: pnpm is not on PATH." >&2 + exit 1 +fi + +# pnpm installs global packages under $PNPM_HOME and refuses to run when its +# bin dir is not on PATH: without this the install aborts with +# ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH. Non-interactive shells do not read +# ~/.bashrc, so both are set here. The default is the directory pnpm itself +# uses on Linux. +export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}" +path_hint=0 +case ":$PATH:" in + *":$PNPM_HOME/bin:"*) ;; + *) path_hint=1; export PATH="$PNPM_HOME/bin:$PATH" ;; +esac + +pnpm add -g --allow-build=@opencode/cli @opencode/cli +opencode --version + +if [[ $path_hint -eq 1 ]]; then + echo "note: $PNPM_HOME/bin is not on your PATH; add it to run 'opencode' directly." >&2 +fi diff --git a/scripts/test_check_commit.py b/scripts/test_check_commit.py index 4e4940429e..037bc8fd56 100644 --- a/scripts/test_check_commit.py +++ b/scripts/test_check_commit.py @@ -55,20 +55,20 @@ class BodyLineLengthTests(unittest.TestCase): self.assert_ok(message) def test_line_at_limit_passes(self): - line = "x " * 38 # 76 chars, breakable - self.assertEqual(len(line), 76) + line = "x " * 40 # 80 chars, breakable + self.assertEqual(len(line), 80) self.assert_ok(":bug: Fix crash\n\n" + line + "\n") def test_line_one_over_limit_fails(self): - line = "x " * 38 + "x" # 77 chars, breakable - self.assertEqual(len(line), 77) + line = "x " * 40 + "x" # 81 chars, breakable + self.assertEqual(len(line), 81) error = self.assert_fail(":bug: Fix crash\n\n" + line + "\n") - self.assertIn("76", error) + self.assertIn("80", error) def test_long_body_line_fails(self): long_line = "word " * 20 # 100 chars, breakable error = self.assert_fail(":bug: Fix crash\n\n" + long_line + "\n") - self.assertIn("76", error) + self.assertIn("80", error) self.assertIn("line 3", error) def test_subject_is_not_checked(self): @@ -106,6 +106,35 @@ class BodyLineLengthTests(unittest.TestCase): self.assertIn("line 4", error) +class ForbiddenTrailerTests(unittest.TestCase): + def assert_ok(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertTrue(ok, error) + self.assertIsNone(error) + + def assert_fail(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertFalse(ok) + self.assertIsNotNone(error) + return error + + def test_body_without_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nSigned-off-by: Someone \n") + + def test_co_authored_by_fails(self): + error = self.assert_fail(":bug: Fix crash\n\nBody.\n\nCo-authored-by: Someone \n") + self.assertIn("Co-authored-by", error) + + def test_co_authored_by_case_insensitive_fails(self): + self.assert_fail(":bug: Fix crash\n\nBody.\n\nco-authored-by: Someone \n") + + def test_co_authored_by_in_body_fails(self): + self.assert_fail(":bug: Fix crash\n\nCo-authored-by: Someone \nBody.\n") + + def test_similar_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nCo-developed-by: Someone \n") + + class SubjectRulesRegressionTests(unittest.TestCase): """Guard the pre-existing validators against accidental breakage.""" @@ -121,15 +150,34 @@ class SubjectRulesRegressionTests(unittest.TestCase): ok, _ = check_commit.check_subject_no_trailing_dot(":bug: Fix crash.") self.assertFalse(ok) - def test_subject_at_70_chars_passes(self): - # ":bug: " is 6 chars, so 64 chars of text reach exactly 70. - ok, error = check_commit.check_subject_length(":bug: " + "S" * 64) + def test_subject_at_80_chars_passes(self): + # ":bug: " is 6 chars, so 74 chars of text reach exactly 80. + ok, error = check_commit.check_subject_length(":bug: " + "S" * 74) self.assertTrue(ok, error) - def test_subject_over_70_chars_fails(self): - ok, error = check_commit.check_subject_length(":bug: " + "S" * 65) + def test_subject_over_80_chars_fails(self): + ok, error = check_commit.check_subject_length(":bug: " + "S" * 75) self.assertFalse(ok) - self.assertIn("70", error) + self.assertIn("80", error) + + def test_merge_subject_skips_length_check(self): + # Git generates Merge subjects from branch names; the limit is skipped. + subject = "Merge branch 'some-very-long-branch-name-that-keeps-going' into develop with extra text here" + self.assertGreater(len(subject), 80) + ok, error = check_commit.check_subject_length(subject) + self.assertTrue(ok, error) + + def test_revert_subject_skips_length_check(self): + subject = 'Revert ":bug: Fix some earlier commit with a fairly long subject line already here" extra' + self.assertGreater(len(subject), 80) + ok, error = check_commit.check_subject_length(subject) + self.assertTrue(ok, error) + + def test_reapply_subject_skips_length_check(self): + subject = 'Reapply ":bug: Fix some earlier commit with a fairly long subject line already here" extra' + self.assertGreater(len(subject), 80) + ok, error = check_commit.check_subject_length(subject) + self.assertTrue(ok, error) if __name__ == "__main__":