Merge branch 'develop' into palba-nitrate-sso

This commit is contained in:
Pablo Alba 2026-06-16 10:23:42 +02:00 committed by GitHub
commit 9d4b71aebe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
223 changed files with 12268 additions and 7171 deletions

View File

@ -1,6 +1,5 @@
description: Create a report to help us improve
name: Bug report
title: "bug: "
type: Bug
labels: ["needs triage"]

View File

@ -1,7 +1,6 @@
description: Suggest an idea for this project.
labels: ["needs triage"]
name: "Feature request"
title: "feature: "
type: Enhancement
body:

84
.github/workflows/tests-backend.yml vendored Normal file
View File

@ -0,0 +1,84 @@
name: "CI: Backend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'backend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'backend/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation

57
.github/workflows/tests-common.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: "CI: Common"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Tests
working-directory: ./common
run: |
./scripts/test

71
.github/workflows/tests-frontend.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: "CI: Frontend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components

93
.github/workflows/tests-integration.yml vendored Normal file
View File

@ -0,0 +1,93 @@
name: "CI: Integration"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
overwrite: true
retention-days: 3

58
.github/workflows/tests-library.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: "CI: Library"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
- 'library/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
- 'library/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./library
run: |
./scripts/test

View File

@ -45,3 +45,8 @@ jobs:
pnpm run fmt:check;
pnpm -r run build;
pnpm -r run types:check;
- name: Tests
working-directory: ./mcp
run: |
pnpm -r run test;

83
.github/workflows/tests-plugins.yml vendored Normal file
View File

@ -0,0 +1,83 @@
name: "CI: Plugins"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'plugins/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example

57
.github/workflows/tests-wasm.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: "CI: WASM"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test

View File

@ -1,411 +0,0 @@
name: "CI"
defaults:
run:
shell: bash
on:
pull_request:
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
concurrency:
group: ${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
if: ${{ !github.event.pull_request.draft }}
name: "Linter"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint Common
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Lint Frontend
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Lint Backend
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Exporter
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Library
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./common
run: |
./scripts/test
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./library
run: |
./scripts/test
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 1/3"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="1/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 2/3"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="2/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 3/3"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="3/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3

View File

@ -0,0 +1,81 @@
---
name: create-pr
description: Create a GitHub PR following Penpot conventions, with a concise engineer-focused description
---
# Create Pull Request
Create a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
## When to Use
- Opening a new pull request
- The user asks to create a PR
- Code changes are ready and committed
## Workflow
### 1. Verify Prerequisites
```bash
git branch --show-current
git log --oneline main..HEAD
```
### 2. Check if Branch is Pushed
```bash
BRANCH=$(git branch --show-current)
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
echo "Branch is pushed, proceeding with PR creation"
else
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
exit 1
fi
```
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
### 3. Create PR Body
Write to `/tmp/pr-body.md` to avoid shell quoting issues:
```bash
cat > /tmp/pr-body.md << 'EOF'
**Note:** This PR was created with AI assistance.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
EOF
```
### 4. Create the PR
Follow title and description format from `mem:workflow/creating-prs` and `mem:workflow/creating-commits`.
```bash
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
```
### 5. What NOT to Include
- ❌ List of files changed (visible in diff)
- ❌ Testing steps (CI handles this)
- ❌ Screenshots unless UI-visible
- ❌ Migration notes unless breaking changes
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
## Key Principles
- **Write for humans.** The diff shows what changed. The description explains why.
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
- **Skip the obvious.** Don't explain what `git diff` already shows.

View File

@ -39,8 +39,8 @@ Identify:
| Field | Source | Rule |
|-------|--------|------|
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. |
| **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). |
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. |
| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). |
| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. |
| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. |
| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. |
@ -101,8 +101,7 @@ Create:
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "<label1>" \
--label "<label2>" \
--label "community contribution" \ # only if PR has this label
--milestone "<milestone>" \
--project "Main" \
--body-file /tmp/issue-body.md
@ -198,12 +197,10 @@ rm -f /tmp/issue-body.md /tmp/pr-body.md
| PR has | Issue gets |
|--------|-----------|
| `bug` | `bug` |
| `enhancement` | `enhancement` |
| `community contribution` | `community contribution` |
| `bug`, `enhancement` | *(skip — redundant with Issue Type)* |
| `backport candidate` | *(skip — workflow label)* |
| `team-qa` | *(skip — workflow label)* |
| No user-facing label | Infer from title: `:bug:``bug`, `:sparkles:``enhancement` |
## Issue Type mapping

View File

@ -48,7 +48,7 @@ python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
**Exclusion rules (issue-level):**
- `no changelog` label — Chore/refactor work that doesn't need a changelog entry
- `release blocker` label — Blocked issues not yet ready for changelog
- `Task` issue type — Internal chores are not user-facing; filter these out after fetching
- `Task` issue type — Internal chores are not user-facing; automatically excluded by `gh.py`. Use `--include-tasks` to override.
- **Rejected project status** — Issues with a "Rejected" status in the "Main" project board are automatically excluded by `gh.py`. This project-level status (independent of the GitHub issue `state`) indicates the issue was rejected from the release. Use `--include-rejected` to override.
**Exclusion rules (PR-level):**
@ -347,71 +347,233 @@ if closed:
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
### 10. Cross-reference milestone PRs against the changelog
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in
the milestone may not close any tracked issue. After writing, run a full
cross-reference to catch gaps:
After all edits and cross-referencing are complete, generate a structured
anomaly report and save it to `CHANGES-ISSUES.md` (overwriting if exists).
This provides a persistent record of any discrepancies between the milestone
and the changelog.
Run this self-contained script:
```bash
# List all merged PRs in the milestone
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
python3 << 'PYEOF'
import json, re, subprocess, sys
# Extract PR numbers from the changelog section
python3 -c "
import json, re
MILESTONE = "<MILESTONE>"
CHANGES_MD = "CHANGES.md"
OUTPUT = "CHANGES-ISSUES.md"
with open('CHANGES.md') as f:
# Fetch milestone issues (all states)
result = subprocess.run(
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_issues = json.loads(result.stdout)
issue_by_num = {i['number']: i for i in all_issues}
# Fetch milestone PRs (all states)
result = subprocess.run(
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_prs = json.loads(result.stdout)
pr_by_num = {p['number']: p for p in all_prs}
# Read changelog
with open(CHANGES_MD) as f:
content = f.read()
# Extract the version section (adjust regex to match the actual version)
match = re.search(r'## <MILESTONE> \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
section = match.group(1)
m = re.search(rf'## {MILESTONE} \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
section = m.group(1) if m else ""
# Collect issue and PR references from the changelog section
changelog_issues = set()
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)', section):
changelog_issues.add(int(num))
for num in re.findall(r'\[Github #(\d+)\]', section):
changelog_issues.add(int(num))
# Collect all PR numbers referenced
changelog_prs = set()
for m in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
changelog_prs.add(int(m))
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
changelog_prs.add(int(num))
for num in re.findall(r'PR:\[(\d+)\]', section):
changelog_prs.add(int(num))
# Collect all milestone PRs (filtered)
with open('/tmp/milestone-prs.json') as f:
milestone_prs = json.load(f)
# Determine valid (non-excluded) milestone issues
EXCLUDED_LABELS = {'release blocker', 'no changelog'}
EXCLUDED_ISSUE_TYPES = {'Task'}
EXCLUDED_PROJECT_STATUS = {'Rejected'}
milestone_merged = {pr['number'] for pr in milestone_prs}
valid_issues = []
for issue in all_issues:
labels = set(issue.get('labels', []))
if issue.get('state') != 'CLOSED': continue
if issue.get('issue_type') in EXCLUDED_ISSUE_TYPES: continue
if issue.get('project_status') in EXCLUDED_PROJECT_STATUS: continue
if EXCLUDED_LABELS & labels: continue
valid_issues.append(issue)
valid_nums = {i['number'] for i in valid_issues}
# PRs in milestone but not in changelog
missing = sorted(milestone_merged - changelog_prs)
print(f'Milestone merged PRs: {len(milestone_merged)}')
print(f'Changelog referenced PRs: {len(changelog_prs)}')
print(f'PRs in milestone but NOT in changelog: {len(missing)}')
for num in missing:
pr = next(p for p in milestone_prs if p['number'] == num)
print(f' #{num} {pr[\"title\"][:80]}')
"
# --- Gather anomalies ---
anomalies = []
# Type 1: Entries in changelog that should be excluded
for num in sorted(changelog_issues):
issue = issue_by_num.get(num)
if issue is None:
anomalies.append({
'type': 'should_remove',
'severity': 'HIGH',
'number': num,
'title': '',
'reason': 'Issue not found in milestone (deleted or moved)'
})
continue
labels = set(issue.get('labels', []))
reasons = []
if issue.get('state') != 'CLOSED':
reasons.append(f'state is "{issue["state"]}" (should be CLOSED)')
if 'release blocker' in labels:
reasons.append('has "release blocker" label')
if 'no changelog' in labels:
reasons.append('has "no changelog" label')
if issue.get('issue_type') == 'Task':
reasons.append(f'issue_type is Task (internal chore)')
if issue.get('project_status') == 'Rejected':
reasons.append('project_status is Rejected')
if reasons:
anomalies.append({
'type': 'should_remove',
'severity': 'MEDIUM' if issue.get('issue_type') == 'Task' else 'HIGH',
'number': num,
'title': issue.get('title', '')[:80],
'reason': '; '.join(reasons)
})
# Type 2: Valid issues not in changelog
for num in sorted(valid_nums - changelog_issues):
issue = issue_by_num[num]
info = {
'type': 'missing',
'severity': 'MEDIUM',
'number': num,
'title': issue['title'][:80],
'issue_type': issue['issue_type'],
'closing_prs': issue.get('closing_prs', []),
'note': ''
}
# Check for duplicate (same PR as existing entry)
existing = []
for pr_num in issue.get('closing_prs', []):
for cl_num in changelog_issues:
cl_issue = issue_by_num.get(cl_num)
if cl_issue and pr_num in cl_issue.get('closing_prs', []):
existing.append(f'#{cl_num}')
if existing:
info['note'] = f'DUPLICATE: same PR as existing entry(ies): {", ".join(existing)}'
# Check closing PRs not merged
unmerged = []
for pr_num in issue.get('closing_prs', []):
pr = pr_by_num.get(pr_num)
if pr is None:
unmerged.append(f'#{pr_num} (unknown)')
elif pr.get('state') != 'MERGED':
unmerged.append(f'#{pr_num} (state={pr["state"]})')
if unmerged:
info['note'] = (info['note'] + '; ' if info['note'] else '') + f'Closing PRs not merged: {", ".join(unmerged)}'
anomalies.append(info)
# Type 3: PRs in changelog that are not merged
for pr_num in sorted(changelog_prs):
pr = pr_by_num.get(pr_num)
if pr is None:
anomalies.append({
'type': 'unmerged_pr',
'severity': 'HIGH',
'number': pr_num,
'title': '',
'reason': 'PR not found in milestone PR list'
})
elif pr.get('state') != 'MERGED':
anomalies.append({
'type': 'unmerged_pr',
'severity': 'HIGH',
'number': pr_num,
'title': pr.get('title', '')[:80],
'reason': f'state={pr["state"]} (should be MERGED)'
})
# --- Write report to CHANGES-ISSUES.md ---
with open(OUTPUT, 'w') as f:
f.write(f'# Changelog Anomaly Report — {MILESTONE}\n\n')
f.write(f'Generated: {__import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M UTC")}\n\n')
f.write('---\n\n')
# Summary
n_remove = sum(1 for a in anomalies if a['type'] == 'should_remove')
n_missing = sum(1 for a in anomalies if a['type'] == 'missing')
n_pr = sum(1 for a in anomalies if a['type'] == 'unmerged_pr')
f.write(f'## Summary\n\n')
f.write(f'- **Issues to remove from changelog:** {n_remove}\n')
f.write(f'- **Valid issues missing from changelog:** {n_missing}\n')
f.write(f'- **Unmerged PRs referenced:** {n_pr}\n')
f.write(f'- **Total anomalies:** {len(anomalies)}\n\n')
if not anomalies:
f.write('✅ No anomalies found. The changelog is fully consistent with the milestone.\n\n')
else:
# Type 1
if n_remove:
f.write(f'## Issues to Remove\n\n')
f.write('These entries are in the changelog but should be excluded based on current issue metadata.\n\n')
for a in anomalies:
if a['type'] != 'should_remove': continue
badge = '🔴' if a['severity'] == 'HIGH' else '🟡'
f.write(f'{badge} **#{a["number"]}**')
if a.get('title'): f.write(f' — {a["title"]}')
f.write(f'\n - Reason: {a["reason"]}\n\n')
# Type 2
if n_missing:
f.write(f'## Valid Issues Not in Changelog\n\n')
f.write('These issues are closed, non-excluded milestone items that lack a changelog entry.\n\n')
for a in anomalies:
if a['type'] != 'missing': continue
f.write(f'❓ **#{a["number"]}** — {a["title"]}\n')
f.write(f' - Type: {a["issue_type"]}, Closing PRs: {a["closing_prs"]}\n')
if a.get('note'): f.write(f' - Note: {a["note"]}\n')
f.write('\n')
# Type 3
if n_pr:
f.write(f'## Unmerged PRs Referenced in Changelog\n\n')
f.write('These PR numbers appear in the changelog but are not merged.\n\n')
for a in anomalies:
if a['type'] != 'unmerged_pr': continue
f.write(f'🔴 **#{a["number"]}**')
if a.get('title'): f.write(f' — {a["title"]}')
f.write(f'\n - {a["reason"]}\n\n')
# Appendix: counts
f.write('---\n\n')
f.write(f'## Context\n\n')
f.write(f'- Milestone total issues (all states): {len(all_issues)}\n')
f.write(f'- Valid issues after exclusions: {len(valid_issues)}\n')
f.write(f'- Issues referenced in changelog: {len(changelog_issues)}\n')
f.write(f'- PRs referenced in changelog: {len(changelog_prs)}\n')
print(f"Anomaly report written to {OUTPUT}")
PYEOF
```
For each missing PR found, decide whether it should be added to the
changelog or is legitimately excluded (check its labels).
This generates `CHANGES-ISSUES.md` with three sections:
1. **Issues to Remove** — Entries in the changelog that should be excluded based
on current issue metadata (labels, type, project status, or deletion).
2. **Valid Issues Not in Changelog** — Closed, non-excluded milestone issues
that lack a changelog entry (with notes on duplicates and unmerged closing PRs).
3. **Unmerged PRs Referenced** — PRs in the changelog that are not merged.
Also verify that no closed-unmerged PRs remain in the changelog:
```bash
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
import json, sys
data = json.load(sys.stdin)
closed = [p for p in data if p['state'] == 'CLOSED']
if closed:
print('WARNING: CLOSED (unmerged) PRs in milestone:')
for p in closed:
print(f' #{p[\"number\"]} {p[\"title\"][:80]}')
"
```
**Post-edit audit checklist:**
- ✅ All referenced PRs are merged (no closed-unmerged artifacts)
- ✅ Every merged milestone PR is either in the changelog or excluded by label
- ✅ PR and issue counts are internally consistent
- ✅ No false-positive PR-to-issue associations
The report is overwritten each time it's generated, reflecting the current
state of the milestone and changelog.
## Key Principles

View File

@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`.
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.

View File

@ -0,0 +1,160 @@
# Creating Issues
Create GitHub issues only on explicit request. Use `gh` CLI authenticated to `penpot/penpot`.
## Title Derivation
Derive the title from the source material (bug report, user feedback, feature request, etc.) — not from any pre-existing title which may be auto-generated or stale.
### Bug titles (descriptive present tense)
Describe the symptom as it appears to the user. Format: `[Where] [present-tense verb] when [condition]`.
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
Do **not** start bug titles with "Fix" or any imperative verb — state what's broken, not command a fix.
### Feature / Enhancement titles (imperative mood)
Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
### Universal rules
- **Include the "where"** — specify the UI location or module (e.g. "in the sidebar", "on the stroke options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, `[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality
- **Two problems → cover both** — if the description has two distinct but related issues, capture both joined by "and"
## Metadata
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
## Issue Body Template
Write the body to a temp file to avoid shell quoting issues:
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
## Creating the Issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Derived title>" \
--label "<label>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
## Setting the Issue Type
`gh issue create` can't set Issue Type directly. Use GraphQL after creation.
**Issue Type IDs for penpot/penpot:**
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task
**Set it:**
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
## Verification
```bash
gh issue view <NUMBER> --repo penpot/penpot \
--json title,labels,milestone,projectItems \
--jq '{title, milestone: .milestone.title, labels: [.labels[].name], projects: [.projectItems[].title]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
## Cleanup
```bash
rm -f /tmp/issue-body.md
```
## See Also
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`

View File

@ -23,6 +23,16 @@
- Remove stray debug log in exporter upload-resource (by @iot2edge) [#9270](https://github.com/penpot/penpot/issues/9270) (PR: [#9272](https://github.com/penpot/penpot/pull/9272))
- Release pool connection during font variant creation (by @Dexterity104) [#9286](https://github.com/penpot/penpot/issues/9286) (PR: [#9287](https://github.com/penpot/penpot/pull/9287))
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109))
- Add list view mode to color picker UI [#4420](https://github.com/penpot/penpot/issues/4420) (PR: [#9953](https://github.com/penpot/penpot/pull/9953))
- Use Clipboard API consistently across the application (by @MilosM348) [#6514](https://github.com/penpot/penpot/issues/6514) (PR: [#9188](https://github.com/penpot/penpot/pull/9188))
- Use `$` as DTCG token/group discriminator and make `$description` optional [#8342](https://github.com/penpot/penpot/issues/8342) (PR: [#9912](https://github.com/penpot/penpot/pull/9912))
- Match version preview banner text to History sidebar labels (by @MilosM348) [#9503](https://github.com/penpot/penpot/issues/9503) (PR: [#9697](https://github.com/penpot/penpot/pull/9697))
- Use "copia" as duplicate suffix for Spanish (by @Rene0422) [#9623](https://github.com/penpot/penpot/issues/9623) (PR: [#9671](https://github.com/penpot/penpot/pull/9671))
- Harden CORS middleware to not reflect Origin with credentials enabled [#9659](https://github.com/penpot/penpot/issues/9659) (PR: [#9675](https://github.com/penpot/penpot/pull/9675))
- Revert token migrations on clashing names to prevent data loss [#9816](https://github.com/penpot/penpot/issues/9816) (PR: [#9950](https://github.com/penpot/penpot/pull/9950))
- Update contributing guidelines with current issue tags and CSS linting rules [#9900](https://github.com/penpot/penpot/issues/9900) (PR: [#9418](https://github.com/penpot/penpot/pull/9418))
- Add composite typography token input to the Design sidebar [#9932](https://github.com/penpot/penpot/issues/9932) (PR: [#9128](https://github.com/penpot/penpot/pull/9128), [#9375](https://github.com/penpot/penpot/pull/9375), [#8749](https://github.com/penpot/penpot/pull/8749))
- Avoid deduplicating temporary export files to prevent stale content (by @yong2bba) [#9970](https://github.com/penpot/penpot/issues/9970) (PR: [#9959](https://github.com/penpot/penpot/pull/9959))
### :bug: Bugs fixed
@ -63,8 +73,46 @@
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
- Fix prototype interaction targets appearing in View Mode automatically when library component changes (by @jeffrey701) [#9049](https://github.com/penpot/penpot/issues/9049) (PR: [#9695](https://github.com/penpot/penpot/pull/9695))
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
- Prevent viewers from overwriting file thumbnails (by @jony376) [#9284](https://github.com/penpot/penpot/issues/9284) (PR: [#9285](https://github.com/penpot/penpot/pull/9285))
- Fix plugin API showing incorrect error messages for invalid operations (by @bitcompass) [#9417](https://github.com/penpot/penpot/issues/9417) (PR: [#9486](https://github.com/penpot/penpot/pull/9486))
- Add inactivity timeout to SSE sessions to match Streamable HTTP sessions [#9432](https://github.com/penpot/penpot/issues/9432) (PR: [#9464](https://github.com/penpot/penpot/pull/9464))
- Fix component variant switching behaving differently on two identical copies (by @MischaPanch) [#9498](https://github.com/penpot/penpot/issues/9498) (PR: [#9434](https://github.com/penpot/penpot/pull/9434))
- Populate is-indirect flag on file libraries from relation graph (by @Dexterity104) [#9506](https://github.com/penpot/penpot/issues/9506) (PR: [#9289](https://github.com/penpot/penpot/pull/9289))
- Add missing error message for invalid shadow token [#9583](https://github.com/penpot/penpot/issues/9583) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
- Fix moving a component in a library triggering stale update notification in dependent files [#9629](https://github.com/penpot/penpot/issues/9629) (PR: [#9616](https://github.com/penpot/penpot/pull/9616))
- Fix newly created token not visible when placed above existing tokens in the tree [#9711](https://github.com/penpot/penpot/issues/9711) (PR: [#9803](https://github.com/penpot/penpot/pull/9803))
- Fix B(V) input label misalignment in HSB color picker [#9731](https://github.com/penpot/penpot/issues/9731) (PR: [#9793](https://github.com/penpot/penpot/pull/9793))
- Fix text style name input appending font name instead of replacing it when edited [#9785](https://github.com/penpot/penpot/issues/9785) (PR: [#9784](https://github.com/penpot/penpot/pull/9784))
- Fix shadow token creation not allowing empty blur or spread value [#9808](https://github.com/penpot/penpot/issues/9808) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
- Fix thinner line in path when its stroke is deleted and added again [#9823](https://github.com/penpot/penpot/issues/9823) (PR: [#9836](https://github.com/penpot/penpot/pull/9836))
- Fix layers panel perceivable lag when displaying changes [#9834](https://github.com/penpot/penpot/issues/9834)
- Fix settings form visual layout broken after recent contribution [#9882](https://github.com/penpot/penpot/issues/9882) (PR: [#9883](https://github.com/penpot/penpot/pull/9883))
- Fix crash when duplicating shapes with fill/stroke properties [#9893](https://github.com/penpot/penpot/issues/9893) (PR: [#9647](https://github.com/penpot/penpot/pull/9647))
- Fix S3 storage failing with IRSA/Web Identity Token credentials (by @jpc2350) [#9927](https://github.com/penpot/penpot/issues/9927) (PR: [#9928](https://github.com/penpot/penpot/pull/9928))
- Fix onboarding template spinner stuck after failed template download (by @jeffrey701) [#9931](https://github.com/penpot/penpot/issues/9931) (PR: [#9504](https://github.com/penpot/penpot/pull/9504))
- Fix stroke caps not working correctly when there are other nodes in the middle of a path [#9987](https://github.com/penpot/penpot/issues/9987) (PR: [#9989](https://github.com/penpot/penpot/pull/9989))
- Fix missing three dots button for column and row edit menu in WebKit/Safari [#9993](https://github.com/penpot/penpot/issues/9993) (PR: [#9994](https://github.com/penpot/penpot/pull/9994))
- Fix exported path with strokes being cut off in SVG file [#9995](https://github.com/penpot/penpot/issues/9995) (PR: [#9996](https://github.com/penpot/penpot/pull/9996))
- Fix French Canada locale falling back to French translations instead of French Canadian (by @alexismo) [#10017](https://github.com/penpot/penpot/issues/10017) (PR: [#10027](https://github.com/penpot/penpot/pull/10027))
## 2.16.0 (Unreleased)
## 2.16.0
### :boom: Breaking changes & Deprecations
@ -90,7 +138,6 @@
- Duplicate token group [#9638](https://github.com/penpot/penpot/issues/9638) (PR: [#8886](https://github.com/penpot/penpot/pull/8886))
- Copy token name from contextual menu [#9639](https://github.com/penpot/penpot/issues/9639) (PR: [#8566](https://github.com/penpot/penpot/pull/8566))
- Add drag-to-change for numeric inputs in workspace sidebar (by @RenzoMXD) [#2466](https://github.com/penpot/penpot/issues/2466) (PR: [#8536](https://github.com/penpot/penpot/pull/8536))
- Add CSS linter [#9636](https://github.com/penpot/penpot/issues/9636) (PR: [#8592](https://github.com/penpot/penpot/pull/8592))
- Add per-group add button for typographies (by @eureka0928) [#5275](https://github.com/penpot/penpot/issues/5275) (PR: [#8895](https://github.com/penpot/penpot/pull/8895))
- Add Find & Replace for text content and layer names (by @statxc) [#7108](https://github.com/penpot/penpot/issues/7108) (PR: [#8899](https://github.com/penpot/penpot/pull/8899), [#9687](https://github.com/penpot/penpot/pull/9687))
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [#8773](https://github.com/penpot/penpot/issues/8773) (PR: [#8874](https://github.com/penpot/penpot/pull/8874))
@ -121,10 +168,9 @@
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252))
- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179))
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
- Clarify self-hosted OIDC configuration for containerized (by @sancfc) [#9764](https://github.com/penpot/penpot/issues/9764) (PR: [#9758](https://github.com/penpot/penpot/pull/9758))
- Update User Guide with 2.16 features (by @myfunnyandy) [#9767](https://github.com/penpot/penpot/issues/9767) (PR: [#9768](https://github.com/penpot/penpot/pull/9768))
- Improve file validation performance and fix orphan shape detection [#9790](https://github.com/penpot/penpot/issues/9790) (PR: [#9789](https://github.com/penpot/penpot/pull/9789))
- Add v2.16 release notes (What's new modal) [#9945](https://github.com/penpot/penpot/issues/9945) (PR: [#9940](https://github.com/penpot/penpot/pull/9940))
- Enable multi-instance horizontal scaling for MCP server [#10000](https://github.com/penpot/penpot/issues/10000) (PR: [#10013](https://github.com/penpot/penpot/pull/10013))
### :bug: Bugs fixed
@ -186,7 +232,6 @@
- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [#9249](https://github.com/penpot/penpot/issues/9249) (PR: [#9250](https://github.com/penpot/penpot/pull/9250))
- Fix Settings Update button enabled state (by @moorsecopers99) [#9090](https://github.com/penpot/penpot/issues/9090) (PR: [#9091](https://github.com/penpot/penpot/pull/9091))
- Fix library updates reappearing after reload [#9326](https://github.com/penpot/penpot/issues/9326) (PR: [#9563](https://github.com/penpot/penpot/pull/9563))
- Fix dependency libraries visible after unlinking main library [#9331](https://github.com/penpot/penpot/issues/9331) (PR: [#9511](https://github.com/penpot/penpot/pull/9511))
- Fix internal error on margins [#9309](https://github.com/penpot/penpot/issues/9309) (PR: [#9311](https://github.com/penpot/penpot/pull/9311))
- Remove drag-to-change when token applied on numeric input [#9313](https://github.com/penpot/penpot/issues/9313) (PR: [#9314](https://github.com/penpot/penpot/pull/9314))
- Fix extra input on canvas background [#9359](https://github.com/penpot/penpot/issues/9359) (PR: [#9360](https://github.com/penpot/penpot/pull/9360))
@ -194,20 +239,16 @@
- Fix several color picker issues [#9556](https://github.com/penpot/penpot/issues/9556) (PR: [#9558](https://github.com/penpot/penpot/pull/9558))
- Fix asset icon broken on Asset tab [#9587](https://github.com/penpot/penpot/issues/9587) (PR: [#9612](https://github.com/penpot/penpot/pull/9612))
- Fix text fill color stops updating in multiselect with texts [#9608](https://github.com/penpot/penpot/issues/9608) (PR: [#9549](https://github.com/penpot/penpot/pull/9549))
- Fix editing a legacy text element silently detaches its color token [#9255](https://github.com/penpot/penpot/issues/9255) (PR: [#9525](https://github.com/penpot/penpot/pull/9525))
- Fix token application to grid paddings [#9494](https://github.com/penpot/penpot/issues/9494) (PR: [#9630](https://github.com/penpot/penpot/pull/9630))
- Fix file crashing when switching a variant [#9259](https://github.com/penpot/penpot/issues/9259) (PR: [#9147](https://github.com/penpot/penpot/pull/9147))
- Fix set activation after renaming [#9329](https://github.com/penpot/penpot/issues/9329) (PR: [#9545](https://github.com/penpot/penpot/pull/9545))
- Fix font selection position hiding available fonts [#9489](https://github.com/penpot/penpot/issues/9489) (PR: [#9499](https://github.com/penpot/penpot/pull/9499))
- Fix numeric input changes not saved when clicking on viewport [#9491](https://github.com/penpot/penpot/issues/9491) (PR: [#9548](https://github.com/penpot/penpot/pull/9548))
- Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590))
- Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626))
- Fix incorrect error message when applying tokens while editing text [#9620](https://github.com/penpot/penpot/issues/9620) (PR: [#9708](https://github.com/penpot/penpot/pull/9708))
- Fix standalone tokens ordering separated from token groups [#9733](https://github.com/penpot/penpot/issues/9733) (PR: [#9736](https://github.com/penpot/penpot/pull/9736))
- Fix delete invitation modal readability in light theme [#9737](https://github.com/penpot/penpot/issues/9737) (PR: [#9747](https://github.com/penpot/penpot/pull/9747))
- Fix team invitation not automatically accepted after account validation [#9776](https://github.com/penpot/penpot/issues/9776) (PR: [#9782](https://github.com/penpot/penpot/pull/9782))
- Fix design tokens vanishing from the sidebar when a token name collides with a token-group prefix from another active set (e.g. `a` in one set and `a.b` in another); the colliding token is now kept and rendered as a broken pill [Github #9584](https://github.com/penpot/penpot/issues/9584)
- Fix Plugin API addRulerGuide creating guides on page instead of board (by @girafic) [#8225](https://github.com/penpot/penpot/issues/8225) (PR: [#8632](https://github.com/penpot/penpot/pull/8632))
- Fix text editor not swapping correctly when enabling/disabling WebGL [#10015](https://github.com/penpot/penpot/issues/10015)
- Fix WebGL renderer focus mode leaving artefacts [#10061](https://github.com/penpot/penpot/issues/10061) (PR: [#10091](https://github.com/penpot/penpot/pull/10091))
- Fix double click on text selecting underlying element when WebGL render is enabled [#10080](https://github.com/penpot/penpot/issues/10080) (PR: [#10123](https://github.com/penpot/penpot/pull/10123))
- Fix publishing or unpublishing file as library failing with unexpected state found error [#10094](https://github.com/penpot/penpot/issues/10094) (PR: [#10093](https://github.com/penpot/penpot/pull/10093))
- Fix team invitation failing when email address contains consecutive dots in domain [#10097](https://github.com/penpot/penpot/issues/10097) (PR: [#10096](https://github.com/penpot/penpot/pull/10096))
- Add detailed error messages for unspecified import errors [#9759](https://github.com/penpot/penpot/issues/9759) (PR: [#9886](https://github.com/penpot/penpot/pull/9886))
## 2.15.4

View File

@ -5,8 +5,8 @@
We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities via email to: **[support@penpot.app]**
Please report vulnerabilities through the [GitHub Security Advisories](https://github.com/penpot/penpot/security/advisories
) feature in the Penpot repository.
### What to include:

View File

@ -7,6 +7,9 @@ list.
## Security
* Noob Researcher
* Kirubakaran N
* Alisher (@7megaumka7)
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
* Vaibhav Shukla

View File

@ -4,9 +4,9 @@
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
org.clojure/tools.namespace {:mvn/version "1.5.1"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-10"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@ -17,7 +17,7 @@
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"}
io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"}
;; Minimal dependencies required by lettuce, we need to include them
;; explicitly because clojure dependency management does not support
;; yet the BOM format.
@ -25,7 +25,7 @@
io.micrometer/micrometer-observation {:mvn/version "1.14.2"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
com.google.guava/guava {:mvn/version "33.4.8-jre"}
com.google.guava/guava {:mvn/version "33.6.0-jre"}
funcool/yetti
{:git/tag "v11.10"
@ -34,13 +34,13 @@
:exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc
{:mvn/version "1.3.1093"}
{:mvn/version "1.3.1108"}
metosin/reitit-core {:mvn/version "0.9.1"}
metosin/reitit-core {:mvn/version "0.10.1"}
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.11"}
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@ -51,7 +51,7 @@
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
org.jsoup/jsoup {:mvn/version "1.21.2"}
org.jsoup/jsoup {:mvn/version "1.22.2"}
org.im4java/im4java
{:git/tag "1.4.0-penpot-2"
:git/sha "e2b3e16"
@ -63,18 +63,18 @@
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
dawran6/emoji {:mvn/version "0.2.0"}
markdown-clj/markdown-clj {:mvn/version "1.12.4"}
markdown-clj/markdown-clj {:mvn/version "1.12.8"}
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}
software.amazon.awssdk/sts {:mvn/version "2.44.4"}}
software.amazon.awssdk/s3 {:mvn/version "2.46.7"}
software.amazon.awssdk/sts {:mvn/version "2.46.7"}}
:paths ["src" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.5"}
{com.bhauman/rebel-readline {:mvn/version "0.1.7"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
org.clojure/data.csv {:mvn/version "1.1.1"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
@ -83,7 +83,7 @@
:build
{:extra-deps
{io.github.clojure/tools.build {:mvn/version "0.10.10"}}
{io.github.clojure/tools.build {:mvn/version "0.10.14"}}
:ns-default build}
:test

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"

View File

@ -545,6 +545,12 @@
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)})))))
;; When email verification is disabled and an inactive profile already
;; exists, reject the registration — the email is already taken.
(not (contains? cf/flags :email-verification))
(ex/raise :type :validation
:code :email-already-exists)
:else
(let [elapsed? (elapsed-verify-threshold? profile)
reports? (eml/has-reports? conn (:email profile))

View File

@ -974,6 +974,12 @@
{:id id})
file)
(= (:is-shared file) (:is-shared params))
;; File is already in the desired state (idempotent);
;; this can happen when the frontend sends a duplicate
;; request due to optimistic updates or race conditions.
file
:else
(ex/raise :type :validation
:code :invalid-shared-state

View File

@ -311,11 +311,30 @@
:object-id object-id
:tag tag})))
(defn- delete-file-object-thumbnails!
"Soft-deletes multiple object thumbnails in a single UPDATE statement
with RETURNING, then touches all returned media objects."
[{:keys [::db/conn ::sto/storage]} object-ids]
(let [ids (db/create-array conn "text" (seq object-ids))
sql (str/concat
"UPDATE file_tagged_object_thumbnail"
" SET deleted_at = now()"
" WHERE object_id = ANY(?)"
" AND deleted_at IS NULL"
" RETURNING media_id")
rows (db/exec! conn [sql ids])]
(doseq [{:keys [media-id]} rows]
(sto/touch-object! storage media-id))))
(def ^:private schema:delete-file-object-thumbnail
[:map {:title "delete-file-object-thumbnail"}
[:file-id ::sm/uuid]
[:object-id [:string {:max 250}]]])
(def ^:private schema:delete-file-object-thumbnails
[:map {:title "delete-file-object-thumbnails"}
[:object-ids [:vector {:max 200} [:string {:max 250}]]]])
(sv/defmethod ::delete-file-object-thumbnail
{::doc/added "1.19"
::doc/module :files
@ -329,6 +348,30 @@
(delete-file-object-thumbnail! file-id object-id))
nil)))
(sv/defmethod ::delete-file-object-thumbnails
{::doc/added "1.19"
::doc/module :files
::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
[:file-thumbnail-ops/global]]
::sm/params schema:delete-file-object-thumbnails
::audit/skip true}
[cfg {:keys [::rpc/profile-id object-ids]}]
(when (seq object-ids)
;; Extract unique file-ids from object-ids for permission checks
(let [file-ids (->> object-ids
(map thc/get-file-id)
(into #{}))]
;; Check permissions for each unique file using a single connection
(db/run! cfg (fn [{:keys [::db/conn]}]
(doseq [file-id file-ids]
(files/check-edition-permissions! conn profile-id file-id))))
;; Delete all matching thumbnails in one transaction
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(-> cfg
(update ::sto/storage sto/configure conn)
(delete-file-object-thumbnails! object-ids))
nil)))))
;; --- MUTATION COMMAND: create-file-thumbnail
(defn- create-file-thumbnail

View File

@ -89,6 +89,12 @@
email)]
email))
(defn- with-nitrate-licence
[profile cfg]
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
;; --- QUERY: Get profile (own)
@ -106,9 +112,7 @@
(let [profile (-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))]
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
(with-nitrate-licence profile cfg))
(catch Throwable cause
(if (= :not-found (-> cause ex-data :type))
@ -137,7 +141,7 @@
::sm/params schema:update-profile
::sm/result schema:profile
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id fullname lang theme] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}]
;; NOTE: we need to retrieve the profile independently if we use
;; it or not for explicit locking and avoid concurrent updates of
;; the same row/object.
@ -158,6 +162,7 @@
(-> profile
(strip-private-attrs)
(d/without-nils)
(with-nitrate-licence cfg)
(rph/with-meta {::audit/props (audit/profile->props profile)}))))

View File

@ -62,7 +62,7 @@
(def default
{:database-uri "postgresql://postgres/penpot_test"
:redis-uri "redis://redis/1"
:redis-uri "redis://valkey/1"
:auto-file-snapshot-every 1
:file-data-backend "db"})

View File

@ -830,6 +830,49 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/deftest set-file-shared-idempotent
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)})]
;; Share the file
(let [data {::th/type :set-file-shared
::rpc/profile-id (:id profile)
:id (:id file)
:is-shared true}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (true? (-> out :result :is-shared))))
;; Calling set-file-shared with is-shared=true again should be a
;; no-op success (idempotent), not an error.
(let [data {::th/type :set-file-shared
::rpc/profile-id (:id profile)
:id (:id file)
:is-shared true}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (true? (-> out :result :is-shared))))
;; Unshare the file
(let [data {::th/type :set-file-shared
::rpc/profile-id (:id profile)
:id (:id file)
:is-shared false}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (false? (-> out :result :is-shared))))
;; Calling set-file-shared with is-shared=false again should also
;; be a no-op success (idempotent).
(let [data {::th/type :set-file-shared
::rpc/profile-id (:id profile)
:id (:id file)
:is-shared false}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (false? (-> out :result :is-shared))))))
(t/deftest permissions-checks-link-to-library-1
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)

View File

@ -380,3 +380,538 @@
(t/is (nil? (:error out)))
(t/is (map? (:result out))))))
;; --- delete-file-object-thumbnails (batch)
(t/deftest delete-file-object-thumbnails-basic
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
oid3 (thc/fmt-object-id (:id file) page-id (uuid/random) "component")]
;; Create three thumbnails
(doseq [oid [oid1 oid2 oid3]]
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out)))))
;; Verify all three exist and are not soft-deleted
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false
:order-by [[:created-at :asc]]})]
(t/is (= 3 (count rows)))
(doseq [row rows]
(t/is (nil? (:deleted-at row)))))
;; Batch delete all three
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid1 oid2 oid3]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify all three are now soft-deleted
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false
:order-by [[:created-at :asc]]})]
(t/is (= 3 (count rows)))
(doseq [row rows]
(t/is (some? (:deleted-at row)))))))
(t/deftest delete-file-object-thumbnails-empty
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids []}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))))
(t/deftest delete-file-object-thumbnails-non-existent
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; Batch delete non-existent object-ids (no thumbnails were created)
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid1 oid2]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))))
(t/deftest delete-file-object-thumbnails-mixed-exists
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
oid3 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; Create only one thumbnail
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid1
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; Batch delete mix of existing and non-existing
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid1 oid2 oid3]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify oid1 is soft-deleted, others don't exist
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows)))
(t/is (= oid1 (:object-id (first rows))))
(t/is (some? (:deleted-at (first rows)))))))
(t/deftest delete-file-object-thumbnails-already-deleted
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; Create a thumbnail
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; First batch delete
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Second batch delete (idempotent — no rows match deleted_at IS NULL)
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify still 1 row, still soft-deleted, not duplicated
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows)))
(t/is (= oid (:object-id (first rows))))
(t/is (some? (:deleted-at (first rows)))))))
(t/deftest delete-file-object-thumbnails-unauthorized
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
file (th/create-file* 1 {:profile-id (:id profile1)
:project-id (:default-project-id profile1)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; profile1 creates a thumbnail on their file
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile1)
:file-id (:id file)
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; profile2 tries to batch delete thumbnails from profile1's file
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile2)
:object-ids [oid]}
out (th/command! data)]
(t/is (some? (:error out)))
(t/is (th/ex-info? (:error out)))
(t/is (= :not-found (th/ex-type (:error out)))))
;; Verify the thumbnail is NOT deleted
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows)))
(t/is (nil? (:deleted-at (first rows)))))))
(t/deftest delete-file-object-thumbnails-cross-file
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
file2 (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page1-id (first (get-in file1 [:data :pages]))
page2-id (first (get-in file2 [:data :pages]))
oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")]
;; Create thumbnails on both files
(doseq [[oid fid] [[oid1 (:id file1)] [oid2 (:id file2)]]]
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id fid
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out)))))
;; Batch delete from both files in one call
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid1 oid2]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify both are soft-deleted
(let [rows1 (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file1)}
{::db/remove-deleted false})
rows2 (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file2)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows1)))
(t/is (some? (:deleted-at (first rows1))))
(t/is (= 1 (count rows2)))
(t/is (some? (:deleted-at (first rows2)))))))
(t/deftest delete-file-object-thumbnails-cross-file-unauthorized
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
file1 (th/create-file* 1 {:profile-id (:id profile1)
:project-id (:default-project-id profile1)
:is-shared false})
file2 (th/create-file* 2 {:profile-id (:id profile2)
:project-id (:default-project-id profile2)
:is-shared false})
page1-id (first (get-in file1 [:data :pages]))
page2-id (first (get-in file2 [:data :pages]))
oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")]
;; Create thumbnails on both files (by their respective owners)
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile1)
:file-id (:id file1)
:object-id oid1
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile2)
:file-id (:id file2)
:object-id oid2
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; profile1 tries to batch delete thumbnails from both files
;; (profile1 does NOT have access to file2)
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile1)
:object-ids [oid1 oid2]}
out (th/command! data)]
(t/is (some? (:error out)))
(t/is (th/ex-info? (:error out)))
(t/is (= :not-found (th/ex-type (:error out)))))
;; Verify NEITHER thumbnail was deleted (all-or-nothing)
(let [rows1 (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file1)}
{::db/remove-deleted false})
rows2 (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file2)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows1)))
(t/is (nil? (:deleted-at (first rows1))))
(t/is (= 1 (count rows2)))
(t/is (nil? (:deleted-at (first rows2)))))))
(t/deftest delete-file-object-thumbnails-media-touch
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; Create two thumbnails
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid1
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid2
:media {:filename "sample.jpg"
:size 312043
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; Get media-ids for both thumbnails
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{:order-by [[:created-at :asc]]})
mid1 (:media-id (first rows))
mid2 (:media-id (second rows))]
;; Verify storage objects exist (they are created with touched-at already set)
(t/is (some? (th/db-get :storage-object {:id mid1})))
(t/is (some? (th/db-get :storage-object {:id mid2})))
;; Batch delete both thumbnails
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid1 oid2]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; After soft-delete, storage objects should STILL exist
;; (they are only garbage-collected later by storage-gc-touched task)
(t/is (some? (th/db-get :storage-object {:id mid1})))
(t/is (some? (th/db-get :storage-object {:id mid2}))))))
(t/deftest delete-file-object-thumbnails-max-batch
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
cnt 200
oids (vec (repeatedly cnt
#(thc/fmt-object-id (:id file) page-id
(uuid/random) "frame")))]
;; Create 200 thumbnails
(doseq [oid oids]
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out)))))
;; Verify all 200 exist
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= cnt (count rows))))
;; Batch delete all 200 in one call
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids oids}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify all 200 are now soft-deleted
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= cnt (count rows)))
(doseq [row rows]
(t/is (some? (:deleted-at row)))))))
(t/deftest delete-file-object-thumbnails-single
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; Create a single thumbnail
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; Batch delete just one
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify it's soft-deleted
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows)))
(t/is (some? (:deleted-at (first rows)))))))
(t/deftest delete-file-object-thumbnails-same-object-twice-in-batch
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (first (get-in file [:data :pages]))
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
;; Create one thumbnail
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out))))
;; Batch delete with the same object-id listed twice
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid oid]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify it's soft-deleted (only one row)
(let [rows (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows)))
(t/is (some? (:deleted-at (first rows)))))))
(t/deftest delete-file-object-thumbnails-keeps-other-files-intact
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
file2 (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page1-id (first (get-in file1 [:data :pages]))
page2-id (first (get-in file2 [:data :pages]))
oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame")
oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")]
;; Create thumbnails on both files
(doseq [[oid fid] [[oid1 (:id file1)] [oid2 (:id file2)]]]
(let [data {::th/type :create-file-object-thumbnail
::rpc/profile-id (:id profile)
:file-id fid
:object-id oid
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (map? (:result out)))))
;; Delete only thumbnail from file1
(let [data {::th/type :delete-file-object-thumbnails
::rpc/profile-id (:id profile)
:object-ids [oid1]}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; Verify file1's thumbnail is deleted, file2's is not
(let [rows1 (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file1)}
{::db/remove-deleted false})
rows2 (th/db-query :file-tagged-object-thumbnail
{:file-id (:id file2)}
{::db/remove-deleted false})]
(t/is (= 1 (count rows1)))
(t/is (some? (:deleted-at (first rows1))))
(t/is (= 1 (count rows2)))
(t/is (nil? (:deleted-at (first rows2)))))))

View File

@ -12,6 +12,7 @@
[app.db :as db]
[app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.tokens :as tokens]
@ -90,17 +91,26 @@
(t/is (not (contains? result :password))))))
(t/testing "update profile"
(let [data (assoc profile
::th/type :update-profile
::rpc/profile-id (:id profile)
:fullname "Full Name"
:lang "en"
:theme "dark")
out (th/command! data)]
(with-redefs [app.config/flags #{:nitrate}]
(with-redefs [nitrate/add-nitrate-licence-to-profile
(fn [_ profile]
(assoc profile :subscription {:plan :pro}))]
(let [data (assoc profile
::th/type :update-profile
::rpc/profile-id (:id profile)
:fullname "Full Name"
:lang "en"
:theme "dark")
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (map? (:result out)))))
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (map? (:result out)))
(t/is (= "Full Name" (get-in out [:result :fullname])))
(t/is (= "en" (get-in out [:result :lang])))
(t/is (= "dark" (get-in out [:result :theme])))
(t/is (= {:plan :pro}
(:subscription (:result out))))))))
(t/testing "query profile after update"
(let [data {::th/type :get-profile
@ -526,6 +536,65 @@
(t/is (nil? (:error out)))
(t/is (= 0 (:call-count @mock))))))))
(t/deftest prepare-register-and-register-profile-disable-email-verification
;; When disable-email-verification is set and the profile is inactive
;; (e.g. created before the flag was set), re-registering should be
;; rejected with :email-already-exists.
(with-mocks [mock {:target 'app.email/send! :return nil}]
(with-redefs [app.config/flags #{:registration :login-with-password}]
(let [current-token (atom nil)]
;; PREPARE REGISTER: first attempt (no profile exists yet)
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
(reset! current-token token))
;; DO REGISTRATION: creates active profile (email-verification disabled)
(let [data {::th/type :register-profile
:token @current-token}
out (th/command! data)
mdata (-> out :result meta)]
(t/is (nil? (:error out)))
;; No verification email sent
(t/is (= 0 (:call-count @mock)))
;; Session is minted
(t/is (seq (:app.rpc/response-transform-fns mdata))))
;; Force the profile back to inactive to simulate the case where it was
;; created before disable-email-verification was set
(th/db-update! :profile
{:is-active false}
{:email "hello@example.com"})
(th/reset-mock! mock)
;; PREPARE REGISTER: second attempt (inactive profile exists)
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
(reset! current-token token))
;; DO REGISTRATION: second attempt should be rejected
(let [data {::th/type :register-profile
:token @current-token}
out (th/command! data)
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-already-exists))
;; No email sent, profile remains inactive
(t/is (= 0 (:call-count @mock)))
(let [profile (th/db-get :profile {:email "hello@example.com"})]
(t/is (false? (:is-active profile)))))))))
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
;; With email-verification ENABLED (the default), a brand-new
;; profile created via the invitation flow is NOT active yet, so

View File

@ -6,7 +6,7 @@
org.clojure/data.fressian {:mvn/version "1.1.1"}
org.clojure/clojurescript {:mvn/version "1.12.42"}
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
org.apache.commons/commons-pool2 {:mvn/version "2.13.1"}
;; Logging
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"}
@ -15,12 +15,12 @@
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"}
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.42"}
selmer/selmer {:mvn/version "1.13.1"}
selmer/selmer {:mvn/version "1.13.4"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"}
metosin/jsonista {:mvn/version "1.0.0"}
metosin/malli {:mvn/version "0.19.1"}
expound/expound {:mvn/version "0.9.0"}
@ -55,7 +55,7 @@
:aliases
{:dev
{:extra-deps
{org.clojure/tools.namespace {:mvn/version "1.5.0"}
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
thheller/shadow-cljs {:mvn/version "3.2.0"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
com.bhauman/rebel-readline {:mvn/version "0.1.5"}
@ -65,7 +65,7 @@
:build
{:extra-deps
{io.github.clojure/tools.build {:mvn/version "0.10.10"}}
{io.github.clojure/tools.build {:mvn/version "0.10.14"}}
:ns-default build}
:test

View File

@ -4,21 +4,21 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"devDependencies": {
"concurrently": "^9.1.2",
"nodemon": "^3.1.10",
"prettier": "3.5.3",
"concurrently": "^10.0.3",
"nodemon": "^3.1.14",
"prettier": "3.8.4",
"source-map-support": "^0.5.21",
"ws": "^8.18.2"
"ws": "^8.21.0"
},
"dependencies": {
"date-fns": "^4.1.0"
"date-fns": "^4.4.0"
},
"scripts": {
"lint:clj": "clj-kondo --parallel=true --lint src/",

288
common/pnpm-lock.yaml generated
View File

@ -9,48 +9,50 @@ importers:
.:
dependencies:
date-fns:
specifier: ^4.1.0
version: 4.1.0
specifier: ^4.4.0
version: 4.4.0
devDependencies:
concurrently:
specifier: ^9.1.2
version: 9.2.1
specifier: ^10.0.3
version: 10.0.3
nodemon:
specifier: ^3.1.10
version: 3.1.11
specifier: ^3.1.14
version: 3.1.14
prettier:
specifier: 3.5.3
version: 3.5.3
specifier: 3.8.4
version: 3.8.4
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.18.2
version: 8.18.3
specifier: ^8.21.0
version: 8.21.0
packages:
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@ -59,35 +61,25 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
concurrently@10.0.3:
resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==}
engines: {node: '>=22'}
hasBin: true
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@ -98,8 +90,8 @@ packages:
supports-color:
optional: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
@ -118,6 +110,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -126,10 +122,6 @@ packages:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
@ -141,10 +133,6 @@ packages:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@ -153,14 +141,15 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nodemon@3.1.11:
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
nodemon@3.1.14:
resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==}
engines: {node: '>=10'}
hasBin: true
@ -168,12 +157,12 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
prettier@3.8.4:
resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
engines: {node: '>=14'}
hasBin: true
@ -184,20 +173,16 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
engines: {node: '>= 0.4'}
simple-update-notifier@2.0.0:
@ -211,26 +196,22 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -249,12 +230,12 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -269,35 +250,32 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
picomatch: 2.3.2
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.12:
brace-expansion@5.0.6:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
@ -305,10 +283,7 @@ snapshots:
buffer-from@1.1.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
chokidar@3.6.0:
dependencies:
@ -322,30 +297,22 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
cliui@8.0.1:
cliui@9.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
color-convert@2.0.1:
concurrently@10.0.3:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concat-map@0.0.1: {}
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
chalk: 5.6.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
shell-quote: 1.8.4
supports-color: 10.2.2
tree-kill: 1.2.2
yargs: 17.7.2
yargs: 18.0.0
date-fns@4.1.0: {}
date-fns@4.4.0: {}
debug@4.4.3(supports-color@5.5.0):
dependencies:
@ -353,7 +320,7 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
emoji-regex@8.0.0: {}
emoji-regex@10.6.0: {}
escalade@3.2.0: {}
@ -366,14 +333,14 @@ snapshots:
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
has-flag@3.0.0: {}
has-flag@4.0.0: {}
ignore-by-default@1.0.1: {}
is-binary-path@2.1.0:
@ -382,28 +349,26 @@ snapshots:
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
minimatch@3.1.2:
minimatch@10.2.5:
dependencies:
brace-expansion: 1.1.12
brace-expansion: 5.0.6
ms@2.1.3: {}
nodemon@3.1.11:
nodemon@3.1.14:
dependencies:
chokidar: 3.6.0
debug: 4.4.3(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
minimatch: 10.2.5
pstree.remy: 1.1.8
semver: 7.7.3
semver: 7.8.4
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
@ -411,29 +376,27 @@ snapshots:
normalize-path@3.0.0: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
prettier@3.5.3: {}
prettier@3.8.4: {}
pstree.remy@1.1.8: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
require-directory@2.1.1: {}
picomatch: 2.3.2
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
semver@7.7.3: {}
semver@7.8.4: {}
shell-quote@1.8.3: {}
shell-quote@1.8.4: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.8.4
source-map-support@0.5.21:
dependencies:
@ -442,28 +405,22 @@ snapshots:
source-map@0.6.1: {}
string-width@4.2.3:
string-width@7.2.0:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
strip-ansi@6.0.1:
strip-ansi@7.2.0:
dependencies:
ansi-regex: 5.0.1
ansi-regex: 6.2.2
supports-color@10.2.2: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -476,24 +433,23 @@ snapshots:
undefsafe@2.0.5: {}
wrap-ansi@7.0.0:
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
ws@8.18.3: {}
ws@8.21.0: {}
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@17.7.2:
yargs@18.0.0:
dependencies:
cliui: 8.0.1
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 21.1.1
yargs-parser: 22.0.0

View File

@ -0,0 +1 @@
minimumReleaseAge: 0

View File

@ -159,7 +159,7 @@ goog.scope(function () {
it1--, i++
) {
carry += (256 * b58[it1]) >>> 0;
b58[it1] = carry % BASE >>> 0;
b58[it1] = (carry % BASE) >>> 0;
carry = (carry / BASE) >>> 0;
}
if (carry !== 0) {
@ -214,7 +214,7 @@ goog.scope(function () {
it3--, i++
) {
carry += (BASE * b256[it3]) >>> 0;
b256[it3] = carry % 256 >>> 0;
b256[it3] = (carry % 256) >>> 0;
carry = (carry / 256) >>> 0;
}
if (carry !== 0) {

View File

@ -169,6 +169,7 @@
:mcp
:background-blur
:available-viewer-wasm
:stroke-path})
(def all-flags
@ -194,6 +195,7 @@
:enable-render-wasm-dpr
:enable-token-color
:enable-token-shadow
:enable-token-typography-row
:enable-inspect-styles
:enable-feature-fdata-objects-map
:enable-feature-render-wasm

View File

@ -89,14 +89,23 @@
([shape]
(get-shape-filter-bounds shape false))
([shape ignore-shadow-margin?]
(if (or (and (cfh/svg-raw-shape? shape)
(not= :svg (dm/get-in shape [:content :tag])))
;; If no shadows or blur, we return the selrect as is
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0))))))
(cond
;; SVG raw elements (non-root) don't have proper rotated points; use selrect
(and (cfh/svg-raw-shape? shape)
(not= :svg (dm/get-in shape [:content :tag])))
(dm/get-prop shape :selrect)
;; No shadows or blur: use the axis-aligned bounding box from the actual
;; (possibly rotated) points. Using selrect here would be wrong for rotated
;; shapes because selrect stores the unrotated rectangle, not the screen-space bbox.
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0)))))
(-> (dm/get-prop shape :points)
(grc/points->rect))
:else
(let [filters (shape->filters shape)
blur-value (case (-> shape :blur :type)
:layer-blur (or (-> shape :blur :value) 0)

View File

@ -2101,6 +2101,39 @@
(grc/rect->center selrect)
(or (:transform current-shape) (gmt/matrix)))))))
(defn- switch-geom-change-value
[prev-shape current-shape attr]
;; Composite geometry stores absolute coordinates. When preserving a size
;; override across variants, keep the target variant's position and only carry
;; the previous dimensions; otherwise :x/:y can disagree with :selrect/:points.
(let [prev-selrect (:selrect prev-shape)
current-selrect (:selrect current-shape)
final-width (:width prev-selrect)
final-height (:height prev-selrect)
x (:x current-selrect)
y (:y current-selrect)
selrect (assoc current-selrect
:width final-width
:height final-height
:x x
:y y
:x1 x
:y1 y
:x2 (+ x final-width)
:y2 (+ y final-height))]
(case attr
:selrect
selrect
:points
(-> selrect
(grc/rect->points)
(gsh/transform-points
(grc/rect->center selrect)
(or (:transform current-shape) (gmt/matrix)))))))
(defn- equal-geometry?
"Returns true when the value of `attr` in `shape` is considered equal
to the corresponding value in `origin-shape`, ignoring positional
@ -2270,6 +2303,10 @@
(contains? #{:points :selrect :width :height} attr))
(switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr)
(and (contains? #{:points :selrect} attr)
(not path-change?))
(switch-geom-change-value previous-shape current-shape attr)
:else
(get previous-shape attr)))

View File

@ -448,18 +448,22 @@
::oapi/type "string"
::oapi/format "uuid"}})
(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
;; Strict email regex aligned with app.common.spec/email-re.
;; Local part: valid RFC chars, no leading/trailing dot, no consecutive dots.
;; Domain: labels can't start/end with hyphen, no empty labels.
;; TLD: at least 2 alphabetic chars.
(def email-re
#"[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}")
(defn parse-email
[s]
(if (string? s)
(first (re-seq email-re s))
nil))
(when (and (string? s) (re-matches email-re s))
s))
(defn email-string?
[s]
(and (string? s)
(re-seq email-re s)))
(some? (re-matches email-re s))))
(register!
{:type ::email

View File

@ -217,11 +217,11 @@ goog.scope(function () {
// Parse ........-....-....-####-............
int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8;
(int8[9] = rest & 0xff),
((int8[9] = rest & 0xff),
// Parse ........-....-....-....-############
// (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes)
(int8[10] =
((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff);
((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff));
int8[11] = (rest / 0x100000000) & 0xff;
int8[12] = (rest >>> 24) & 0xff;
int8[13] = (rest >>> 16) & 0xff;

View File

@ -2866,18 +2866,14 @@
(t/is (= (get-in rect02' [:selrect :width]) 150))))
(t/deftest test-switch-when-source-master-child-has-touched-geometry
;; Regression: when the previous-shape's geometry has sub-pixel drift
(t/deftest test-switch-skips-composite-geometry-with-subpixel-drift
;; Regression: when the previous-shape's geometry only has sub-pixel drift
;; relative to its source master (a state produced by interactive transform
;; modifiers, e.g. alt-drag duplicate of a variant whose children are
;; component copies), the equal-geometry? guard in update-attrs-on-switch
;; uses exact equality and fails. The :else branch then copies
;; previous-shape's :selrect verbatim onto the freshly-instantiated target,
;; leaving :y correct (the per-attr y skip catches that) but :selrect.y
;; stale. The shape ends up internally inconsistent (:y disagrees with
;; :selrect.y); the renderer reads :selrect, so the child appears at the
;; source variant's position inside a parent that has resized to the
;; target's dimensions — the visible "cut off" symptom.
;; component copies), equal-geometry? must classify it as unchanged and skip
;; copying composite geometry. Otherwise, :selrect/:points can carry stale
;; absolute positions from the source variant onto the freshly-instantiated
;; target, producing the visible "cut off" symptom.
(let [;; ==== Setup
;; A self-contained Input/Button-like component, plus a variant
;; container whose two variants each instance that component
@ -2911,8 +2907,8 @@
;; The copy carries an Input/Button instance (Frame1). Introduce
;; sub-pixel drift in its :width and :selrect.width — the kind of
;; floating-point error produced by the alt-drag modifier path in
;; production. This drift is what defeats equal-geometry?'s
;; exact-equality comparison and lets the bug surface.
;; production. The drift is small enough to be treated as unchanged
;; geometry by equal-geometry?.
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
copy-btn-id (->> (cfh/get-children-ids-with-self (:objects page) (:id copy01))
@ -3035,3 +3031,73 @@
(t/is (= target-rel-y actual-rel-y)
(str "path :selrect.y should match target master layout (expected "
target-rel-y " got " actual-rel-y ")"))))
(t/deftest test-switch-preserves-size-override-at-target-position
(let [move-to (fn [shape x y]
(gsh/move shape (gpt/point (- x (:x shape))
(- y (:y shape)))))
;; ==== Setup: each variant contains the same nested component instance.
;; The nested instance has identical size in both variants, but a different
;; position relative to the variant root.
file (-> (thf/sample-file :file1)
(tho/add-simple-component
:nested-component :nested-main :nested-label
:root-params {:width 100 :height 50}
:child-params {:width 30 :height 10})
(thv/add-variant-with-copy
:v01 :c01 :m01 :c02 :m02 :r01 :r02 :nested-component))
page (thf/current-page file)
r01 (ths/get-shape file :r01)
r02 (ths/get-shape file :r02)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id r01) (:id r02)}
(fn [shape]
(cond
(= (:id shape) (:id r01)) (move-to shape 20 100)
(= (:id shape) (:id r02)) (move-to shape 20 70)
:else shape))
(:objects page)
{})
file (thf/apply-changes file changes)
file (thc/instantiate-component file :c01
:copy01
:children-labels [:copy-r01])
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
copy-r01 (get-in page [:objects (-> copy01 :shapes first)])
;; This is a real geometry override, not float drift. The switch should
;; preserve the overridden size while anchoring composite geometry to
;; the target variant's position.
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy-r01)}
(fn [shape]
(let [new-width 150
sr (:selrect shape)
new-sr (-> sr
(assoc :width new-width)
(assoc :x2 (+ (:x1 sr) new-width)))]
(-> shape
(assoc :width new-width)
(assoc :selrect new-sr)
(assoc :touched #{:geometry-group}))))
(:objects page)
{})
file (thf/apply-changes file changes)
;; ==== Action
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; The width override is preserved, but the target variant position remains
;; authoritative for absolute composite geometry.
(t/is (= 150 (:width rect02')))
(t/is (= (+ (:y copy02') 70) (:y rect02')))
(t/is (= (:y rect02') (get-in rect02' [:selrect :y])))))

View File

@ -188,3 +188,60 @@
(t/is (= false (decode-s "f")))
(t/is (= true (decode-s "1")))
(t/is (= false (decode-s "0")))))
(t/deftest test-email-validation
(t/testing "accepts well-formed email addresses"
(doseq [email ["user@domain.com"
"user.name@domain.com"
"user+tag@domain.com"
"user-name@domain.com"
"user_name@domain.com"
"user123@domain.com"
"USER@DOMAIN.COM"
"u@domain.io"
"user@sub.domain.com"
"user@domain.co.uk"
"user@domain.dev"
"a@bc.co"]]
(t/is (sm/validate ::sm/email email) (str "should accept: " email))
(t/is (= email (sm/decode ::sm/email email sm/json-transformer)))))
(t/testing "rejects domain with consecutive dots (dot-dot)"
(t/is (false? (sm/validate ::sm/email "user@gmail.com..")))
(t/is (false? (sm/validate ::sm/email "user@sub..domain.com")))
(t/is (false? (sm/validate ::sm/email "eissaalbothigi@gmail.com..")))
(t/is (nil? (sm/parse-email "user@gmail.com..")))
(t/is (nil? (sm/parse-email "eissaalbothigi@gmail.com.."))))
(t/testing "rejects domain ending with a dot"
(t/is (false? (sm/validate ::sm/email "user@domain.")))
(t/is (nil? (sm/parse-email "user@domain."))))
(t/testing "rejects domain starting with a dot"
(t/is (false? (sm/validate ::sm/email "user@.domain.com")))
(t/is (nil? (sm/parse-email "user@.domain.com"))))
(t/testing "rejects local part with consecutive dots"
(t/is (false? (sm/validate ::sm/email "user..name@domain.com")))
(t/is (nil? (sm/parse-email "user..name@domain.com"))))
(t/testing "rejects local part starting with a dot"
(t/is (false? (sm/validate ::sm/email ".user@domain.com")))
(t/is (nil? (sm/parse-email ".user@domain.com"))))
(t/testing "rejects label starting or ending with hyphen"
(t/is (false? (sm/validate ::sm/email "user@-domain.com")))
(t/is (false? (sm/validate ::sm/email "user@domain-.com"))))
(t/testing "rejects TLD shorter than 2 chars"
(t/is (false? (sm/validate ::sm/email "user@domain.c"))))
(t/testing "rejects domain without a dot"
(t/is (false? (sm/validate ::sm/email "user@domain"))))
(t/testing "rejects empty or malformed emails"
(t/is (false? (sm/validate ::sm/email "")))
(t/is (false? (sm/validate ::sm/email "@domain.com")))
(t/is (false? (sm/validate ::sm/email "user@")))
(t/is (false? (sm/validate ::sm/email "userdomain.com")))
(t/is (false? (sm/validate ::sm/email "user@@domain.com")))))

View File

@ -1,4 +1,4 @@
FROM ubuntu:24.04 AS base
FROM ubuntu:26.04 AS base
ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
@ -32,7 +32,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v24.15.0 \
ENV NODE_VERSION=v24.16.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@ -416,7 +416,7 @@ RUN set -ex; \
libfreetype6 \
libfontconfig1 \
libglib2.0-0 \
libxml2 \
libxml2-16 \
liblcms2-2 \
libheif1 \
libopenjp2-7 \
@ -425,7 +425,7 @@ RUN set -ex; \
libgomp1 \
libwebpmux3 \
libwebpdemux2 \
libzip4t64 \
libzip5 \
; \
rm -rf /var/lib/apt/lists/*;
@ -458,7 +458,7 @@ ENV LANG='C.UTF-8' \
SERENA_CONTEXT="claude-code" \
PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
COPY --from=penpotapp/imagemagick:7.1.2-24 /opt/imagick /opt/imagick
COPY --from=setup-jvm /opt/jdk /opt/jdk
COPY --from=setup-jvm /opt/clojure /opt/clojure
COPY --from=setup-node /opt/node /opt/node

View File

@ -1,4 +1,4 @@
FROM ubuntu:24.04
FROM ubuntu:26.04
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG='C.UTF-8' \
@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
DEBIAN_FRONTEND=noninteractive \
TZ=Etc/UTC
ARG IMAGEMAGICK_VERSION=7.1.2-13
ARG IMAGEMAGICK_VERSION=7.1.2-24
RUN set -e; \
apt-get -qq update; \
@ -79,13 +79,12 @@ RUN set -e; \
libopenjp2-7 \
libpng16-16 \
librsvg2-2 \
libxml2 \
libtiff6 \
libwebp7 \
libwebpdemux2 \
libwebpmux3 \
libxml2 \
libzip4t64 \
libxml2-16 \
libzip5 \
libzstd1 \
;\
apt-get -qqy clean; \

View File

@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
JAVA_HOME="/opt/jdk" \
DEBIAN_FRONTEND=noninteractive \
NODE_VERSION=v24.15.0 \
NODE_VERSION=v24.16.0 \
TZ=Etc/UTC
RUN set -ex; \
@ -16,6 +16,7 @@ RUN set -ex; \
ca-certificates \
curl \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
RUN set -eux; \
@ -115,13 +116,13 @@ RUN set -ex; \
woff2 \
; \
find tmp/usr/share/zoneinfo/* -type d ! -name 'Etc' |xargs rm -rf; \
apt-get clean; \
rm -rf /var/lib /var/cache; \
rm -rf /usr/include; \
mkdir -p /opt/data/assets; \
mkdir -p /opt/penpot; \
chown -R penpot:penpot /opt/penpot; \
chown -R penpot:penpot /opt/data; \
rm -rf /var/lib/apt/lists/*;
chown -R penpot:penpot /opt/data;
COPY --from=build /opt/jre /opt/jre
COPY --from=build /opt/node /opt/node

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v24.15.0 \
NODE_VERSION=v24.16.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers
@ -13,12 +13,14 @@ RUN set -ex; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qq upgrade; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
locale-gen; \
@ -80,6 +82,7 @@ RUN set -ex; \
libzip4t64 \
libzstd1 \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*;
RUN set -eux; \

View File

@ -1,10 +1,15 @@
FROM nginxinc/nginx-unprivileged:1.30.0
FROM nginxinc/nginx-unprivileged:1.30.2-alpine
LABEL maintainer="Penpot <docker@penpot.app>"
USER root
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
apk update; \
apk upgrade; \
apk add --no-cache bash gettext; \
rm -rf /var/cache/apk/*; \
addgroup -g 1001 penpot; \
adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot; \
mkdir -p /opt/data/assets; \
chown -R penpot:penpot /opt/data; \
mkdir -p /etc/nginx/overrides/main.d/; \

View File

@ -13,12 +13,14 @@ RUN set -ex; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qq upgrade; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
locale-gen; \

View File

@ -1,10 +1,14 @@
FROM nginxinc/nginx-unprivileged:1.30.0
FROM nginxinc/nginx-unprivileged:1.30.2-alpine
LABEL maintainer="Penpot <docker@penpot.app>"
USER root
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot;
apk update; \
apk upgrade; \
rm -rf /var/cache/apk/*; \
addgroup -g 1001 penpot; \
adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot;
ARG BUNDLE_PATH="./bundle-storybook/"
COPY $BUNDLE_PATH /var/www/

View File

@ -78,7 +78,7 @@ services:
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}"
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
restart: always
ports:
- 9001:8080
@ -111,7 +111,7 @@ services:
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.15}"
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
restart: always
volumes:
@ -180,13 +180,13 @@ services:
PENPOT_SMTP_SSL: "false"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}"
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}"
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
restart: always
depends_on:

View File

@ -848,6 +848,9 @@ a[href].post-tag:visited {
.illus-techguide {
background-image: url(/img/home-technical-guide.webp);
}
.illus-mcp {
background-image: url(/img/home-mcp-server.webp);
}
.illus-plugins {
background-image: url(/img/home-plugins.webp);
}

View File

@ -18,24 +18,30 @@ eleventyNavigation:
<p>Everything you need to know about how Penpot works.</p>
</a>
</li>
<li class="illus illus-contributing">
<a href="/contributing-guide/">
<h2>Contributing guide →</h2>
<p>How to report bugs, add translations and more.</p>
</a>
</li>
<li class="illus illus-techguide">
<a href="/technical-guide/">
<h2>Technical guide →</h2>
<p>Installation, configuration and architecture.</p>
</a>
</li>
<li class="illus illus-mcp">
<a href="/mcp/">
<h2>MCP server →</h2>
<p>Connect AI agents to your Penpot files for design and development workflows.</p>
</a>
</li>
<li class="illus illus-plugins">
<a href="/plugins/">
<h2>Plugins →</h2>
<p>All about Penpot plugins.</p>
</a>
</li>
<li class="illus illus-contributing">
<a href="/contributing-guide/">
<h2>Contributing guide →</h2>
<p>How to report bugs, add translations and more.</p>
</a>
</li>
<li class="illus illus-faq">
<a href="https://community.penpot.app/c/faq/17">
<h2>FAQs →</h2>

View File

@ -309,6 +309,38 @@ For client-specific setup, use the shared section **Connect your MCP client**.
For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`.
### Setup videos
<figure>
<iframe
width="672px"
height="378px"
src="https://www.youtube.com/embed/hBn1iutWSq4?rel=0"
title="Penpot MCP Server: Remote Setup in 5 Min | OpenCode + OpenRouter"
frameborder="0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen>
</iframe>
<figcaption>Penpot MCP Server Remote setup with OpenCode and OpenRouter</figcaption>
</figure>
<figure>
<iframe
width="672px"
height="378px"
src="https://www.youtube.com/embed/QeSFO0h7GGY?rel=0"
title="Penpot MCP Server Remote Setup in 1 Minute | Cursor"
frameborder="0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen>
</iframe>
<figcaption>Penpot MCP Server Remote setup with Cursor</figcaption>
</figure>
<a id="use-remote"></a>
### Use
@ -391,6 +423,23 @@ Leave this terminal running while you use MCP.
For advanced or repository-based workflows, see the [MCP README](https://github.com/penpot/penpot/blob/main/mcp/README.md) in the Penpot repository.
### Setup video
<figure>
<iframe
width="672px"
height="378px"
src="https://www.youtube.com/embed/xfgf0kKGOoc?rel=0"
title="Penpot MCP Server Local Setup"
frameborder="0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen>
</iframe>
<figcaption>Penpot MCP Server Local setup</figcaption>
</figure>
<a id="connect-local"></a>
### Connect

View File

@ -39,5 +39,5 @@
"markdown-it-anchor": "^9.0.1",
"markdown-it-plantuml": "^1.4.1"
},
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}

711
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

0
docs/pnpm-workspace.yaml Normal file
View File

View File

@ -206,6 +206,12 @@ server {
proxy_pass http://localhost:9001/ws/notifications;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass http://localhost:9001/mcp/ws;
}
# Proxy pass
location / {
proxy_set_header Host $http_host;

View File

@ -4,29 +4,29 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.5.3+sha512.7ac1c919341c213a34dc0d02afb7143c5c26ac26ee8c4782deea821b8ac64d2134a081fd8941dae6e29bbb48f58dfc2b7fbceeccc07cb2f09d219d342a4969ed",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"type": "module",
"dependencies": {
"archiver": "7.0.1",
"archiver": "8.0.0",
"cookies": "^0.9.1",
"date-fns": "^4.1.0",
"date-fns": "^4.4.0",
"generic-pool": "^3.9.0",
"inflation": "^2.1.0",
"ioredis": "^5.10.1",
"ioredis": "^5.11.1",
"playwright": "^1.60.0",
"raw-body": "^3.0.2",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^8.2.0",
"@penpot/svgo": "penpot/svgo#3.3.0",
"undici": "^8.4.1",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},
"devDependencies": {
"ws": "^8.20.1"
"ws": "^8.21.0"
},
"scripts": {
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",

669
exporter/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
allowBuilds:
core-js-pure: false
minimumReleaseAgeExclude:
- lodash@4.17.24
- lodash@4.17.23
overrides:
lodash@<=4.17.23: ^4.17.24
lodash@>=4.0.0 <=4.17.22: ^4.17.23
lodash@>=4.0.0 <=4.17.23: ^4.17.24

View File

@ -14,6 +14,7 @@ rm -rf target
pnpm run build;
cp pnpm-lock.yaml target/;
cp pnpm-workspace.yaml target/;
cp package.json target/;
touch target/pnpm-workspace.yaml;

View File

@ -7,7 +7,7 @@
(ns app.handlers.resources
"Temporal resources management."
(:require
["archiver$default" :as arc]
["archiver" :as arc]
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
@ -41,7 +41,7 @@
(defn create-zip
[& {:keys [resource on-complete on-progress on-error]}]
(let [^js zip (arc/create "zip")
(let [^js zip (new arc/ZipArchive)
^js out (fs/createWriteStream (:path resource))
on-complete (or on-complete (constantly nil))
progress (atom 0)]

View File

@ -6,7 +6,7 @@
(ns app.renderer.svg
(:require
["svgo" :as svgo]
["@penpot/svgo" :as svgo]
["xml-js" :as xml]
[app.browser :as bw]
[app.common.data :as d]

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"browserslist": [
"defaults"
],
@ -17,8 +17,8 @@
"build:app:assets": "node ./scripts/build-app-assets.js",
"build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:wasm": "../render-wasm/build",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:wasm": "../render-wasm/build",
"build:app:libs": "node ./scripts/build-libs.js",
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
@ -46,34 +46,35 @@
"clear:wasm": "cargo clean --manifest-path ../render-wasm/Cargo.toml",
"watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 -h 0.0.0.0 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
},
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/draft-js": "link:packages/draft-js",
"@penpot/mousetrap": "link:packages/mousetrap",
"@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",
"@penpot/ui": "workspace:./packages/ui",
"@playwright/test": "1.60.0",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-themes": "10.3.5",
"@storybook/addon-vitest": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tokens-studio/sd-transforms": "1.2.11",
"@types/node": "^25.5.2",
"@vitest/browser": "4.1.3",
"@vitest/browser-playwright": "^4.1.3",
"@vitest/coverage-v8": "4.1.3",
"@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",
"@penpot/ui": "link:packages/ui",
"@playwright/test": "1.61.0",
"@storybook/addon-docs": "10.4.5",
"@storybook/addon-themes": "10.4.5",
"@storybook/addon-vitest": "10.4.5",
"@storybook/react-vite": "10.4.5",
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^25.9.3",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@zip.js/zip.js": "2.8.26",
"autoprefixer": "^10.4.27",
"compression": "^1.8.1",
"concurrently": "^9.2.1",
"date-fns": "^4.1.0",
"esbuild": "^0.28.0",
"eventsource-parser": "^3.0.8",
"concurrently": "^10.0.3",
"date-fns": "^4.4.0",
"esbuild": "^0.28.1",
"eventsource-parser": "^3.1.0",
"express": "^5.1.0",
"fancy-log": "^2.0.0",
"getopts": "^2.3.0",
@ -84,48 +85,46 @@
"lodash": "^4.18.1",
"lodash.debounce": "^4.0.8",
"map-stream": "0.0.7",
"marked": "^17.0.5",
"marked": "^18.0.5",
"mkdirp": "^3.0.1",
"mustache": "^4.2.0",
"nodemon": "^3.1.14",
"npm-run-all": "^4.1.5",
"opentype.js": "^1.3.4",
"opentype.js": "^2.0.0",
"p-limit": "^7.3.0",
"playwright": "1.60.0",
"postcss": "^8.5.8",
"playwright": "1.61.0",
"postcss": "^8.5.15",
"postcss-clean": "^1.2.2",
"postcss-modules": "^6.0.1",
"postcss-scss": "^4.0.9",
"prettier": "3.8.1",
"prettier": "3.8.4",
"pretty-time": "^1.1.0",
"prop-types": "^15.8.1",
"randomcolor": "^0.6.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-error-boundary": "^6.1.1",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-error-boundary": "^6.1.2",
"react-virtualized": "^9.22.6",
"rimraf": "^6.1.3",
"rxjs": "8.0.0-alpha.14",
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"sax": "^1.4.1",
"sass": "^1.100.0",
"sass-embedded": "^1.100.0",
"sax": "^1.6.0",
"scheduler": "^0.27.0",
"source-map-support": "^0.5.21",
"storybook": "10.3.5",
"style-dictionary": "5.0.0-rc.1",
"stylelint": "^17.4.0",
"storybook": "10.4.5",
"style-dictionary": "5.4.4",
"stylelint": "^17.13.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-scss": "^7.0.0",
"stylelint-scss": "^7.2.0",
"stylelint-use-logical-spec": "^5.0.1",
"svg-sprite": "^2.0.4",
"tdigest": "^0.1.2",
"tinycolor2": "^1.6.0",
"typescript": "^6.0.2",
"ua-parser-js": "2.0.9",
"vite": "^8.0.7",
"vitest": "^4.1.3",
"vite": "^8.0.16",
"vitest": "^4.1.9",
"wait-on": "^9.0.4",
"wasm-pack": "^0.13.1",
"watcher": "^2.3.1",
"workerpool": "^10.0.1",
"xregexp": "^5.1.2"

View File

@ -4,18 +4,18 @@
"description": "Penpot Draft-JS Wrapper",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"immutable": "^5.1.4"
"immutable": "^5.1.6"
},
"peerDependencies": {
"react": ">=0.17.0",
"react-dom": ">=0.17.0"
},
"devDependencies": {
"esbuild": "^0.27.2"
"esbuild": "^0.28.1"
}
}

View File

@ -4,7 +4,7 @@
"description": "Simple library for handling keyboard shortcuts",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"author": "Craig Campbell",
"license": "Apache-2.0 WITH LLVM-exception"
}

View File

@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {

View File

@ -14,23 +14,23 @@
"build": "vite build"
},
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@storybook/react": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@storybook/react": "10.4.5",
"@storybook/react-vite": "10.4.5",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-hooks": "7.1.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.3.5",
"vite-plugin-dts": "^4.5.4"
"storybook": "10.4.5",
"vite-plugin-dts": "^5.0.2"
},
"peerDependencies": {
"react": ">=19.2",

View File

@ -0,0 +1,195 @@
{
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c",
"~:file-id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
"~:created-at": "~m1717759268004",
"~:data": {
"~: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": [
"~ubc508673-9e3b-80bf-8004-77dfa30a2b13"
]
}
},
"~ubc508673-9e3b-80bf-8004-77dfa30a2b13": {
"~#shape": {
"~:y": 100,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 0.5735764363510460,
"~:b": 0.8191520442889918,
"~:c": -0.8191520442889918,
"~:d": 0.5735764363510460,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 55,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Board",
"~:width": 80,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 166.208,
"~:y": 92.816
}
},
{
"~#point": {
"~:x": 212.096,
"~:y": 158.352
}
},
{
"~#point": {
"~:x": 113.792,
"~:y": 227.184
}
},
{
"~#point": {
"~:x": 67.904,
"~:y": 161.648
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 0.5735764363510460,
"~:b": -0.8191520442889918,
"~:c": 0.8191520442889918,
"~:d": 0.5735764363510460,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ubc508673-9e3b-80bf-8004-77dfa30a2b13",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-color": "#FF0000",
"~:stroke-opacity": 1,
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:outer",
"~:stroke-width": 20
}
],
"~:x": 100,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 100,
"~:y": 100,
"~:width": 80,
"~:height": 120,
"~:x1": 100,
"~:y1": 100,
"~:x2": 180,
"~:y2": 220
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 120,
"~:flip-y": null,
"~:shapes": [],
"~:show-content": true
}
}
},
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2",
"~:name": "Page 1"
}
}

View File

@ -0,0 +1,86 @@
{
"~:users": [
{
"~:id": "~u0515a066-e303-8169-8004-73eb4018f4e0",
"~:email": "leia@example.com",
"~:name": "Princesa Leia",
"~:fullname": "Princesa Leia",
"~:is-active": true
}
],
"~:fonts": [],
"~:project": {
"~:id": "~u0515a066-e303-8169-8004-73eb401b5d55",
"~:name": "Drafts",
"~:team-id": "~u0515a066-e303-8169-8004-73eb401977a6"
},
"~:share-links": [],
"~:libraries": [],
"~:file": {
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Rotated Board Stroke Test",
"~:revn": 1,
"~:modified-at": "~m1717759268010",
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
"~:is-shared": false,
"~:version": 48,
"~:project-id": "~u0515a066-e303-8169-8004-73eb401b5d55",
"~:created-at": "~m1717759250257",
"~:data": {
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
"~:options": {
"~:components-v2": true
},
"~:pages": [
"~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2"
],
"~:pages-index": {
"~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2": {
"~#penpot/pointer": [
"~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c",
{
"~:created-at": "~m1717759268024"
}
]
}
}
}
},
"~:team": {
"~:id": "~u0515a066-e303-8169-8004-73eb401977a6",
"~:created-at": "~m1717493865581",
"~:modified-at": "~m1717493865581",
"~:name": "Default",
"~: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,
"~:can-read": true,
"~:is-logged": true,
"~:in-team": true
}
}

View File

@ -71,6 +71,21 @@ export class ViewerPage extends BaseWebSocketPage {
);
}
async setupFileWithRotatedBoardStroke() {
await this.mockRPC(
/get\-view\-only\-bundle\?/,
"viewer/get-view-only-bundle-rotated-board-stroke.json",
);
await this.mockRPC(
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-empty.json",
);
await this.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-rotated-board-stroke.json",
);
}
async setupFileWithComments() {
await this.mockRPC(
/get\-view\-only\-bundle\?/,

View File

@ -318,4 +318,18 @@ test("Color picker color list", async ({ page }) => {
await expect(
colorpicker.getByRole("listitem", { name: "First color" }),
).toBeVisible();
//test show and hide color palette
const paletteToggle = workspacePage.page.getByRole('button', { name: 'Toggle color palette' });
await paletteToggle.click();
const paletteBar = workspacePage.page.getByRole('region', { name: 'Palette bar' });
await expect(paletteBar).toBeVisible();
// Check that color palette is open by checking the presence of a color swatch in the palette
const paletteSwatch = paletteBar.getByRole('button', { name: 'first color' });
await expect(paletteSwatch).toBeVisible();
// Close the color palette
await paletteToggle.click();
await expect(paletteSwatch).not.toBeVisible();
});

View File

@ -271,17 +271,15 @@ test("Multiselection of text and typographies", async ({ page }) => {
await expect(textSection.getByText("Mixed assets")).toBeVisible();
// Select token typography text layer
// TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY
await tokenTypographyTextLayerOne.click();
await expect(textSection).toBeVisible();
await expect(textSection.getByText("Metrophobic")).toBeVisible();
await expect(textSection.getByLabel('token-typo-one')).toBeVisible();
// Select two token typography text layer with different token typography
// TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY
await tokenTypographyTextLayerTwo.click({ modifiers: ["Control"] });
await expect(textSection).toBeVisible();
await expect(
textSection.getByTitle("Font family").getByText("Mixed Font Families"),
textSection.getByText('Mixed tokens'),
).toBeVisible();
//Select plain text layer and typography text layer together

View File

@ -186,11 +186,8 @@ test.describe("Tokens: Apply token", () => {
await tokensSidebar.getByRole("button", { name: "Full" }).click();
const fontSizeInput = workspacePage.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
await expect(fontSizeInput).toBeVisible();
await expect(fontSizeInput).toHaveValue("100");
const tokenRow = workspacePage.rightSidebar.getByLabel('Full');
await expect(tokenRow).toBeVisible();
});
test("User adds shadow token with multiple shadows and applies it to shape", async ({

View File

@ -418,13 +418,9 @@ test.describe("Remapping a single token", () => {
.filter({ hasText: "Some Text" })
.click();
// Verify the shape shows the updated font size value (18)
// This proves the remapping worked and the value update propagated through the reference
const fontSizeInput = workspacePage.rightSidebar.getByRole("textbox", {
name: "Font Size",
});
await expect(fontSizeInput).toBeVisible();
await expect(fontSizeInput).toHaveValue("18");
const tokenPillSidebar = workspacePage.rightSidebar.getByLabel('paragraph-style')
await expect(tokenPillSidebar).toBeVisible();
});
});

View File

@ -0,0 +1,49 @@
import { test, expect } from "@playwright/test";
import { ViewerPage } from "../pages/ViewerPage";
test.beforeEach(async ({ page }) => {
await ViewerPage.init(page);
});
const multipleBoardsFileId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0";
const multipleBoardsPageId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3";
test("[View mode] Export presets are preserved when navigating between boards in inspect mode", async ({
page,
}) => {
const viewer = new ViewerPage(page);
await viewer.setupLoggedInUser();
await viewer.setupFileWithMultipleBoards();
await viewer.goToViewer({
fileId: multipleBoardsFileId,
pageId: multipleBoardsPageId,
});
// 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");
await addExportButton.click();
// Verify the "Export 1 element" button appears, confirming the preset was added
const exportButton = page.getByRole("button", { name: "Export 1 element" });
await expect(exportButton).toBeVisible();
// Navigate to another board
const nextButton = page.getByRole("button", { name: "Next" });
await nextButton.click();
await expect(page).toHaveURL(/&index=1/);
// Navigate back to the first board
const prevButton = page.locator(".main_ui_viewer__viewer-go-prev");
await prevButton.click();
await expect(page).toHaveURL(/&index=0/);
// Export preset should still be visible after returning to the first board
await expect(exportButton).toBeVisible();
});

View File

@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
import { ViewerPage } from "../pages/ViewerPage";
// Issue 8257: outer stroke of a rotated board is cropped in View Mode.
// The SVG viewport must be large enough to contain the stroke of a rotated board.
// A 55° rotated board (80×120) with a 20px outer stroke has a rotated bounding box
// of ~144×134px. The viewport must be at least ~202×192px (bbox + stroke margin).
test.beforeEach(async ({ page }) => {
await ViewerPage.init(page);
});
const rotatedBoardFileId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb1";
const rotatedBoardPageId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb2";
test("Viewer shows full outer stroke of a rotated board without clipping", async ({
page,
}) => {
const viewer = new ViewerPage(page);
await viewer.setupLoggedInUser();
await viewer.setupFileWithRotatedBoardStroke();
await viewer.goToViewer({
fileId: rotatedBoardFileId,
pageId: rotatedBoardPageId,
});
// Wait for the viewer SVG to be rendered
const svg = page.locator("svg[class*='not-fixed']").first();
await expect(svg).toBeVisible();
// The SVG viewBox must be large enough to contain the rotated board plus its
// 20px outer stroke. For a 55° rotated board (80×120):
// - The axis-aligned bounding box of the rotated frame is ~144×134px
// - The outer stroke (20px) adds sqrt(2)*20 ≈ 29px margin on each side
// - So the viewport must be at least ~202×192px
//
// Before the fix, the viewer used the unrotated selrect (80×120) as the viewport,
// causing the stroke to be heavily clipped.
const viewBox = await svg.getAttribute("viewBox");
const [, , vbWidth, vbHeight] = viewBox.split(" ").map(Number);
// The unrotated selrect is 80×120. If the viewport is close to those dimensions,
// the stroke is being clipped (bug). The fixed viewport should be much larger.
expect(vbWidth).toBeGreaterThan(150);
expect(vbHeight).toBeGreaterThan(150);
});

6583
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -9,3 +9,25 @@ patchedDependencies:
'@zip.js/zip.js@2.8.26': patches/@zip.js__zip.js@2.8.26.patch
shamefullyHoist: true
minimumReleaseAge: 0
allowBuilds:
'@parcel/watcher': true
canvas: true
core-js-pure: true
esbuild: true
overrides:
ajv@>=7.0.0-alpha.0 <8.18.0: ^8.18.0
immutable@<3.8.3: ^3.8.3
lodash@<=4.17.23: ^4.17.24
lodash@>=4.0.0 <=4.17.23: ^4.17.24
minimatch@>=10.0.0 <10.2.1: ^10.2.1
minimatch@>=10.0.0 <10.2.3: ^10.2.3
minimatch@>=9.0.0 <9.0.6: ^9.0.6
minimatch@>=9.0.0 <9.0.7: ^9.0.7
postcss@<7.0.36: ^7.0.36
postcss@<8.4.31: ^8.4.31
postcss@<8.5.10: ^8.5.10
yaml@>=2.0.0 <2.8.3: ^2.8.3

View File

@ -9,14 +9,12 @@
{:target :esm
:output-dir "resources/public/js/"
:asset-path "/js"
;; :devtools is dev-only, so it lives under :dev -- shadow merges that map
;; for `watch`/`compile` but not `release`, keeping :devtools-url out of
;; release entirely (shadow spec-checks it as non-empty-string? whenever the
;; key is present, even in release). In the devenv SHADOW_SERVER_URL is
;; always set per workspace (see defaults.env / manage.sh).
:dev {:devtools {:watch-dir "resources/public"
:reload-strategy :full
:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}}
:devtools {:watch-dir "resources/public"
:reload-strategy :full}
:dev {;; allows remote-relay per parallel environment
;; inside :dev so the integration tests won't use it
:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}}
:build-options {:manifest-name "manifest.json"}
:modules
{:shared
@ -92,11 +90,12 @@
{:target :browser
:output-dir "resources/public/js/worker/"
:asset-path "/js/worker"
;; Dev-only; see the :main build above for why :devtools lives under :dev.
:dev {:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]
:browser-inject :main
:watch-dir "resources/public"
:reload-strategy :full}}
:devtools {:watch-dir "resources/public"
:reload-strategy :full
:browser-inject :main}
:dev {;; allows remote-relay per parallel environment
;; inside :dev so the integration tests won't use it
:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}}
:build-options {:manifest-name "manifest.json"}
:modules
{:main

View File

@ -92,15 +92,17 @@
(effect [_ state _]
(when (and wasm/context-initialized?
(not @wasm/context-lost?))
(let [objects (dsh/lookup-page-objects state)]
(doseq [{:keys [type id parent-id]} redo-changes
:when (contains? wasm-structural-change-types type)
:let [shape-id (case type
:add-obj id
:mov-objects parent-id)
shape (get objects shape-id)]
:when shape]
(wasm.api/process-object shape))
(let [objects (dsh/lookup-page-objects state)
shapes
(into []
(keep (fn [{:keys [type id parent-id]}]
(when (contains? wasm-structural-change-types type)
(get objects (case type
:add-obj id
:mov-objects parent-id)))))
redo-changes)]
(wasm.api/process-objects shapes)
(wasm.api/request-render "sync-wasm-structural-changes"))))))
(defn- apply-changes-localy

View File

@ -6,7 +6,7 @@
(ns app.main.data.event
(:require
["ua-parser-js" :as ua]
["@penpot/ua-parser" :as ua]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.json :as json]
@ -66,22 +66,22 @@
(defn- collect-context
[]
(let [uagent (new ua/UAParser)]
(let [result (ua/parse)]
(merge
{:version (:full cf/version)
:locale i18n/*current-locale*}
(let [browser (.getBrowser uagent)]
(let [browser (.getBrowser result)]
{:browser (obj/get browser "name")
:browser-version (obj/get browser "version")})
(let [engine (.getEngine uagent)]
(let [engine (.getEngine result)]
{:engine (obj/get engine "name")
:engine-version (obj/get engine "version")})
(let [os (.getOS uagent)
(let [os (.getOS result)
name (obj/get os "name")
version (obj/get os "version")]
{:os (str name " " version)
:os-version version})
(let [device (.getDevice uagent)]
(let [device (.getDevice result)]
(if-let [type (obj/get device "type")]
{:device-type type
:device-vendor (obj/get device "vendor")
@ -93,7 +93,7 @@
:screen-height (obj/get screen "height")
:screen-color-depth (obj/get screen "colorDepth")
:screen-orientation (obj/get orientation "type")})
(let [cpu (.getCPU uagent)]
(let [cpu (.getCPU result)]
{:device-arch (obj/get cpu "architecture")}))))
(def context

View File

@ -163,31 +163,46 @@
(when (= status "ended")
(dom/trigger-download-uri filename mtype resource-uri)))))
;; TODO: Remove once we support WASM SVG export
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
(defn- wasm-export-enabled?
"WASM export is available: the flag is set AND render-wasm is active for the
current file. When render-wasm is inactive its shape tree isn't loaded, so a
client-side WASM render would crash."
[state]
(and (contains? cf/flags :wasm-export)
(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 request-simple-export
[{:keys [export]}]
(if (and (contains? cf/flags :wasm-export)
(contains? #{:jpeg :webp :png} (:type export)))
(ptk/reify ::request-simple-export-wasm
ptk/EffectEvent
(effect [_ _ _]
(wasm.exports/export-image export)))
(ptk/reify ::request-simple-export
ptk/UpdateEvent
(update [_ state]
(cond-> state
(not (use-wasm-export? state export))
(update :export assoc :in-progress true :id uuid/zero)))
(ptk/reify ::request-simple-export
ptk/UpdateEvent
(update [_ state]
(update state :export assoc :in-progress true :id uuid/zero))
ptk/WatchEvent
(watch [_ state _]
ptk/WatchEvent
(watch [_ state _]
(if (use-wasm-export? state export)
(do
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export))
(rx/empty))
(let [profile-id (:profile-id state)
params {:exports [export]
:profile-id profile-id
:cmd :export-shapes
:wait true
:is-wasm
(and
(features/active-feature? state "render-wasm/v1")
(contains? cf/flags :wasm-export))}]
:is-wasm (wasm-export-enabled? state)}]
(rx/concat
(rx/of ::dwp/force-persist)
@ -221,10 +236,7 @@
:cmd cmd
:profile-id profile-id
:force-multiple true
:is-wasm
(and
(features/active-feature? state "render-wasm/v1")
(contains? cf/flags :wasm-export))}
:is-wasm (wasm-export-enabled? state)}
(some? name)
(assoc :name name))

View File

@ -26,3 +26,18 @@
(dom/trigger-download-uri filename 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")]
(wapi/create-uri blob)))
(defn export-pdf
[{:keys [suffix name] :as params}]
(let [url (export-pdf-uri params)
filename (str name (or suffix "") ".pdf")]
(dom/trigger-download-uri filename "application/pdf" url)
(js/queueMicrotask #(wapi/revoke-uri url))
nil))

View File

@ -9,6 +9,7 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb]
[app.common.logging :as log]
[app.common.time :as ct]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
@ -57,45 +58,57 @@
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions)
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions)
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "start-plugin!"))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions]}]
(st/emit! (pflag/clear plugin-id)
(save-current-plugin plugin-id))
(-> (.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(do
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "load-plugin!")
(st/emit! (remove-current-plugin plugin-id))))))
(defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?]
@ -135,10 +148,15 @@
(defn close-plugin!
[{:keys [plugin-id]}]
(try
(.ɵunloadPlugin ^js ug/global plugin-id)
(catch :default e
(.error js/console "Error" e))))
(let [unload-plugin (unchecked-get ug/global "ɵunloadPlugin")]
(if (fn? unload-plugin)
(try
(unload-plugin plugin-id)
(catch :default e
(.error js/console "Error" e)))
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "close-plugin!"))))
(defn close-current-plugin
[& {:keys [close-only-edition-plugins?]}]

View File

@ -17,6 +17,7 @@
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.interactions :as ctsi]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.comments :as dcmt]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
@ -219,7 +220,8 @@
(ptk/reify ::update-page-position-data
ptk/WatchEvent
(watch [_ state _]
(if (features/active-feature? state "render-wasm/v1")
(if (and (features/active-feature? state "render-wasm/v1")
(contains? cf/flags :available-viewer-wasm))
(let [objects (dsh/lookup-page-objects state file-id page-id)
shapes
@ -581,6 +583,13 @@
(update [_ state]
(d/dissoc-in state [:viewer-local :nav-scroll]))))
(defn update-exports-cache
[shapes-key exports]
(ptk/reify ::update-exports-cache
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:inspect-exports-cache shapes-key] exports))))
(defn complete-animation
[]
(ptk/reify ::complete-animation

View File

@ -504,8 +504,7 @@
(rx/of (dwu/append-undo entry stack-undo?)))
(rx/empty))))))
(rx/take-until stoper-s))
(rx/of (mcp/notify-other-tabs-disconnect)))))
(rx/take-until stoper-s)))))
ptk/EffectEvent
(effect [_ _ _]

View File

@ -743,7 +743,8 @@
(update :fills translate-fills)
(update :strokes translate-strokes)
(d/update-when :content #(txt/transform-nodes process-text-node %))
(d/update-when :position-data #(mapv process-text-node %)))))
;; Removes the position-data so it's regenerated
(dissoc :position-data))))
;; Analyze the rchange and replace staled media and
;; references to the new uploaded media-objects.

View File

@ -47,6 +47,20 @@
(let [wglobal (:workspace-global state)]
(layout/persist-layout-state! wglobal)))))
(defn toggle-palette
"Toggle the palette tool and change the library it uses"
[selected]
(ptk/reify ::toggle-palette
ptk/WatchEvent
(watch [_ _ _]
(rx/of (layout/toggle-layout-flag :colorpalette)
(mbc/event colorpalette-selected-broadcast-key selected)))
ptk/EffectEvent
(effect [_ state _]
(let [wglobal (:workspace-global state)]
(layout/persist-layout-state! wglobal)))))
(defn start-picker
[]
(ptk/reify ::start-picker

View File

@ -10,13 +10,10 @@
[app.common.uri :as u]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.register :refer [mcp-plugin-id]]
[app.util.i18n :refer [tr]]
[app.plugins.register :as preg]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -29,7 +26,7 @@
{:code "plugin.js"
:name "Penpot MCP Plugin"
:version 2
:plugin-id mcp-plugin-id
:plugin-id preg/mcp-plugin-id
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
@ -39,6 +36,14 @@
(defonce interval-sub (atom nil))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/force-disconnect {})
(ptk/data-event ::connect)))))
(defn finalize-workspace?
[event]
(= (ptk/type event) :app.main.data.workspace/finalize-workspace))
@ -72,45 +77,6 @@
(rx/dispose! @interval-sub)
(reset! interval-sub nil)))
(declare manage-mcp-notification)
(defn handle-pong
[{:keys [id data]}]
(ptk/reify ::handle-pong
ptk/UpdateEvent
(update [_ state]
(let [mcp-state (get state :mcp)]
(cond
(= "connected" (:connection-status data))
(update state :mcp assoc :connected-tab id)
(and (= "disconnected" (:connection-status data))
(= id (:connected-tab mcp-state)))
(update state :mcp dissoc :connected-tab)
:else
state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (manage-mcp-notification)))))
;; This event will arrive when a new workspace is open in another tab
(defn handle-ping
[]
(ptk/reify ::handle-ping
ptk/WatchEvent
(watch [_ state _]
(let [conn-status (get-in state [:mcp :connection-status])]
(rx/of (mbc/event :mcp/pong {:connection-status conn-status}))))))
(defn notify-other-tabs-disconnect
[]
(ptk/reify ::notify-other-tabs-disconnect
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/pong {:connection-status "disconnected"})))))
;; This event will arrive when the mcp is enabled in the dashboard
(defn update-mcp-status
[value]
@ -121,12 +87,10 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/merge
(rx/of (manage-mcp-notification))
(case value
true (rx/of (ptk/data-event ::connect))
false (rx/of (ptk/data-event ::disconnect))
nil)))))
(case value
true (rx/of (connect-mcp))
false (rx/of (ptk/data-event ::disconnect))
nil))))
(defn update-mcp-connection-status
[value]
@ -137,20 +101,13 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of (manage-mcp-notification)
(mbc/event :mcp/pong {:connection-status value})))))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connected-tab (:session-id state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (mbc/event :mcp/force-disconect {})
(ptk/data-event ::connect)))))
;; Only one MCP plugin instance may be active across browser tabs.
;; When this tab becomes connected, tell every other tab to
;; disconnect (which also stops their reconnect watcher). Otherwise
;; several tabs stay connected at once and the MCP server reports
;; "multiple instances connected" and the agent fails.
(when (= "connected" value)
(rx/of (mbc/event :mcp/force-disconnect {}))))))
;; This event will arrive when the user selects disconnect on the menu
;; or there is a broadcast message for disconnection
@ -166,77 +123,54 @@
(effect [_ _ _]
(stop-reconnect-watcher!))))
(defn- manage-mcp-notification
[]
(ptk/reify ::manage-mcp-notification
ptk/WatchEvent
(watch [_ state _]
(let [mcp-state (get state :mcp)
mcp-enabled? (-> state :profile :props :mcp-enabled)
current-tab-id (get state :session-id)
connected-tab-id (get mcp-state :connected-tab)]
(if mcp-enabled?
(if (= connected-tab-id current-tab-id)
(rx/of (ntf/hide))
(rx/of (ntf/dialog
{:content (tr "notifications.mcp.active-in-another-tab")
:cancel {:label (tr "labels.dismiss")
:callback #(st/emit! (ntf/hide)
(ev/event {::ev/name "dismiss-mcp-tab-switch"
::ev/origin "workspace-notification"}))}
:accept {:label (tr "labels.switch")
:callback #(st/emit! (connect-mcp)
(ev/event {::ev/name "confirm-mcp-tab-switch"
::ev/origin "workspace-notification"}))}})))
(rx/of (ntf/hide)))))))
(defn init-mcp
[stream]
(->> (rp/cmd! :get-current-mcp-token)
(rx/tap
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; Wait for plugins runtime to be initialized before starting the MCP plugin.
;; This ensures global.ɵloadPlugin is available when start-plugin! is called.
(->> (rx/from (preg/wait-for-runtime))
(rx/mapcat
(fn [_]
(->> (rp/cmd! :get-current-mcp-token)
(rx/tap
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
(when (= status "connected")
(start-reconnect-watcher!))
(st/emit! (update-mcp-connection-status status))
(log/info :hint "MCP STATUS" :status status))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
(when (= status "connected")
(start-reconnect-watcher!))
(st/emit! (update-mcp-connection-status status))
(log/info :hint "MCP STATUS" :status status))
:on
(fn [event cb]
(when-let [event
(case event
"disconnect" ::disconnect
"connect" ::connect
nil)]
:on
(fn [event cb]
(when-let [event
(case event
"disconnect" ::disconnect
"connect" ::connect
nil)]
(let [stopper (rx/filter finalize-workspace? stream)]
(->> stream
(rx/filter (ptk/type? event))
(rx/take-until stopper)
(rx/subs! #(cb))))))}}))))
(rx/ignore)))
(let [stopper (rx/filter finalize-workspace? stream)]
(->> stream
(rx/filter (ptk/type? event))
(rx/take-until stopper)
(rx/subs! #(cb))))))}})))))))))
(defn init
[]
(ptk/reify ::init
ptk/UpdateEvent
(update [_ state]
(update state :mcp assoc :connected-tab (:session-id state) :active true))
(update state :mcp assoc :active true))
ptk/WatchEvent
(watch [_ state stream]
@ -251,22 +185,8 @@
(rx/merge
(init-mcp stream)
(rx/of (mbc/event :mcp/ping {}))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/ping))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map handle-ping))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/pong))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map handle-pong))
(->> mbc/stream
(rx/filter (mbc/type? :mcp/force-disconect))
(rx/filter (mbc/type? :mcp/force-disconnect))
(rx/filter (fn [{:keys [id]}]
(not= session-id id)))
(rx/map deref)
@ -276,9 +196,9 @@
(->> mbc/stream
(rx/filter (mbc/type? :mcp/enable))
(rx/mapcat (fn [_]
;; NOTE: we don't need an explicit
;; connect because the plugin has
;; auto-connect
;; Re-init so the force-disconnect
;; listener is set up now that MCP
;; is enabled.
(rx/of (update-mcp-status true)
(init)))))
@ -286,7 +206,6 @@
(rx/filter (mbc/type? :mcp/disable))
(rx/mapcat (fn [_]
(rx/of (update-mcp-status false)
(init)
(user-disconnect-mcp))))))
(rx/take-until stoper-s))))))

View File

@ -88,6 +88,7 @@
(let [page (dsh/lookup-page state file-id page-id)
uris (into #{} xf:collect-file-media (:objects page))]
(rx/merge
(rx/of (ptk/data-event ::initialized page-id))
(->> (rx/from uris)
(rx/map #(http/fetch-data-uri % false))
(rx/ignore))

View File

@ -957,10 +957,10 @@
txt/text-transform-attrs)))
values (cond-> values
(number? (:line-height values))
(update :line-height str)
(update :line-height #(str (mth/precision % 2)))
(number? (:letter-spacing values))
(update :letter-spacing str))
(update :letter-spacing #(str (mth/precision % 2))))
typ-id (uuid/next)
typ (-> (if multiple?

View File

@ -85,40 +85,70 @@
(let [request (create-request file-id page-id shape-id tag)]
(q/enqueue-unique queue request (partial render-thumbnail state file-id page-id shape-id tag))))
(defn- clear-thumbnail-batch
[]
(let [pending (volatile! nil)]
(ptk/reify ::clear-thumbnail-batch
ptk/UpdateEvent
(update [_ state]
(let [items (get state ::thumbnails-deletion-queue)]
(when (seq items)
(vreset! pending items))
(dissoc state ::thumbnails-deletion-queue)))
ptk/WatchEvent
(watch [_ _ _]
(let [items (reduce-kv (fn [acc object-id uri]
(when (str/starts-with? uri "blob:")
(tm/schedule-on-idle (partial wapi/revoke-uri uri)))
(conj acc object-id))
[]
@pending)]
(l/dbg :hint "clear-thumbnail-batch" :total (count items))
(->> (rx/from (partition-all 200 items))
(rx/mapcat
(fn [batch]
(l/dbg :hint "clear-thumbnail-batch" :batch-size (count batch))
(->> (rp/cmd! :delete-file-object-thumbnails
{:object-ids (vec batch)})
(rx/catch rx/empty)
(rx/ignore))))))))))
(defn remove-from-deletion-queue
"Removes an object-id from the pending deletion queue in state.
Used by update-thumbnail to cancel a pending batched delete before
creating a new thumbnail for the same object."
[object-id]
(ptk/reify ::remove-from-deletion-queue
ptk/UpdateEvent
(update [_ state]
(update state ::thumbnails-deletion-queue dissoc object-id))))
(defn clear-thumbnail
([file-id page-id frame-id tag]
(clear-thumbnail file-id (thc/fmt-object-id file-id page-id frame-id tag)))
([file-id object-id]
(let [pending (volatile! false)]
(ptk/reify ::clear-thumbnail
cljs.core/IDeref
(-deref [_] object-id)
([_file-id object-id]
(ptk/reify ::clear-thumbnail
cljs.core/IDeref
(-deref [_] object-id)
ptk/UpdateEvent
(update [_ state]
ptk/UpdateEvent
(update [_ state]
(let [uri (dm/get-in state [:thumbnails object-id])]
(l/dbg :hint "clear-thumbnail" :object-id object-id :uri uri)
(-> state
(update :thumbnails
(fn [thumbs]
(if-let [uri (get thumbs object-id)]
(do (vreset! pending uri)
(dissoc thumbs object-id))
thumbs)))
(update :thumbnails-meta dissoc object-id)))
(update ::thumbnails-deletion-queue assoc object-id uri)
(update :thumbnails dissoc object-id)
(update :thumbnails-meta dissoc object-id))))
ptk/WatchEvent
(watch [_ _ _]
(if-let [uri @pending]
(do
(l/trc :hint "clear-thumbnail" :uri uri)
(when (str/starts-with? uri "blob:")
(tm/schedule-on-idle (partial wapi/revoke-uri uri)))
(let [params {:file-id file-id
:object-id object-id}]
(->> (rp/cmd! :delete-file-object-thumbnail params)
(rx/catch rx/empty)
(rx/ignore))))
(rx/empty)))))))
ptk/WatchEvent
(watch [_ _ stream]
(let [stopper-s (->> stream
(rx/filter (ptk/type? ::clear-thumbnail)))]
(->> (rx/timer 200)
(rx/take 1)
(rx/map (fn [_] (clear-thumbnail-batch)))
(rx/take-until stopper-s)))))))
(defn assoc-thumbnail
[object-id uri]
@ -173,7 +203,8 @@
:tag (or tag "frame")}]
(rx/merge
(rx/of (assoc-thumbnail object-id uri))
(rx/of (assoc-thumbnail object-id uri)
(remove-from-deletion-queue object-id))
(->> (rp/cmd! :create-file-object-thumbnail params)
(rx/catch rx/empty)
(rx/ignore))))))
@ -305,6 +336,14 @@
;; and interrupt any ongoing update-thumbnail process
;; related to current frame-id
(->> all-commits-s
;; Ensure each clear-thumbnail event is dispatched in its
;; own macrotask tick. Without this, multiple changes
;; arriving on the same synchronous tick would emit
;; several clear-thumbnail events back-to-back, causing
;; their debounce timers (rx/take-until stopper-s) to
;; race and potentially leave multiple clear-thumbnail-batch
;; timers alive simultaneously.
(rx/observe-on :async)
(rx/mapcat (fn [[tag frame-id]]
(rx/of (clear-thumbnail file-id page-id frame-id tag)))))

View File

@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cph]
[app.common.time :as ct]
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
[app.common.types.tokens-lib :as ctob]
@ -155,6 +156,14 @@
(def mcp
(l/derived :mcp st/state))
(def mcp-key-expired?
(l/derived (fn [state]
(when-let [expires-at (some->> (:access-tokens state)
(some #(when (= (:type %) "mcp") %))
:expires-at)]
(> (ct/now) expires-at)))
st/state))
(def workspace-drawing
(l/derived :workspace-drawing st/state))

View File

@ -30,6 +30,7 @@
[app.common.types.shape.layout :as ctl]
[app.config :as cfg]
[app.main.fonts :as fonts]
[app.main.render-viewer-wasm :as rwv]
[app.main.ui.context :as muc]
[app.main.ui.shapes.bool :as bool]
[app.main.ui.shapes.circle :as circle]
@ -524,32 +525,6 @@
(for [shape shapes]
[:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]]))
(defn render-to-canvas
[objects canvas bounds scale object-id on-render]
(let [width (.-width canvas)
height (.-height canvas)
os-canvas (js/OffscreenCanvas. width height)]
(try
(when (wasm.api/init-canvas-context os-canvas)
(wasm.api/initialize-viewport
objects scale bounds
:background-opacity 0
:on-render
(fn []
(wasm.api/render-sync-shape object-id)
(ts/raf
(fn []
(let [bitmap (.transferToImageBitmap os-canvas)
ctx2d (.getContext canvas "2d")]
(.clearRect ctx2d 0 0 width height)
(.drawImage ctx2d bitmap 0 0)
(dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id))
(wasm.api/clear-canvas)
(on-render)))))))
(catch :default e
(js/console.error "Error initializing canvas context:" e)
false))))
(mf/defc object-wasm
{::mf/wrap [mf/memo]}
[{:keys [objects object-id skip-children scale on-render] :as props}]
@ -574,7 +549,7 @@
(p/fmap
(fn [ready?]
(when ready?
(render-to-canvas objects canvas bounds scale object-id on-render))))))))
(rwv/render-to-canvas objects canvas bounds scale object-id on-render))))))))
[:canvas {:ref canvas-ref
:width (* scale width)

View File

@ -0,0 +1,269 @@
;; 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.main.render-viewer-wasm
"WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)."
(:require
[app.common.data.macros :as dm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]
[app.util.timers :as ts]
[app.util.webapi :as webapi]
[goog.events :as events]
[promesa.core :as p]
[rumext.v2 :as mf]))
;; The WASM module is a single global instance; serialize offscreen work.
(defonce ^:private wasm-render-queue (atom (p/resolved nil)))
(defn- enqueue-wasm-render!
[task]
(let [next-p (-> @wasm-render-queue
(p/handle (fn [_ _] (task))))]
(reset! wasm-render-queue (p/handle next-p (fn [_ _] nil)))
next-p))
(defonce ^:private viewer-snapshot
(atom {:os-canvas nil
:page-key nil
:canvas-w 0
:canvas-h 0
:dpr 1}))
(defn- reset-viewer-snapshot! []
(reset! viewer-snapshot
{:os-canvas nil
:page-key nil
:canvas-w 0
:canvas-h 0
:dpr 1}))
(defn- draw-bitmap!
[canvas os-canvas object-id vis-w vis-h finish]
(ts/raf
(fn []
(let [ctx2d (.getContext canvas "2d")]
(.clearRect ctx2d 0 0 vis-w vis-h)
;; Draw directly from OffscreenCanvas so it can be reused across passes.
(.drawImage ctx2d os-canvas 0 0 vis-w vis-h)
(dom/set-attribute! canvas "id" (str "screenshot-" object-id))
(finish)))))
(defn- viewer-disable-wasm-ui-overlay!
"Workspace WASM UI (rulers + rounded viewport frame) is composited in
`present_frame`; the viewer must not show that chrome."
[]
(wasm.api/set-rulers-frame-visible! false)
(wasm.api/set-rulers-visible! false))
(defn- viewer-apply-layer-mask!
[include-ids clear-fills-ids]
(wasm.api/clear-render-include-filter!)
(when (seq include-ids)
(wasm.api/set-render-include-filter! include-ids))
(doseq [id clear-fills-ids]
(wasm.api/use-shape id)
(wasm.api/clear-shape-fills!)))
(defn- viewer-restore-layer-mask!
[page-objects clear-fills-ids]
(wasm.api/clear-render-include-filter!)
(doseq [id clear-fills-ids]
(wasm.api/use-shape id)
(wasm.api/set-shape-fills id (get-in page-objects [id :fills] []) false)))
(defn- viewer-do-render!
[page-objects canvas os-canvas object-id vis-w vis-h scale size
include-ids clear-fills-ids finish]
(viewer-disable-wasm-ui-overlay!)
(viewer-apply-layer-mask! include-ids clear-fills-ids)
(wasm.api/set-viewer-viewport! scale size)
(wasm.api/render-sync-shape object-id)
(viewer-restore-layer-mask! page-objects clear-fills-ids)
(draw-bitmap! canvas os-canvas object-id vis-w vis-h finish))
(defn- render-to-canvas*
[objects canvas bounds scale object-id on-render]
(p/create
(fn [resolve _reject]
(let [width (.-width canvas)
height (.-height canvas)
prev-disable @wasm/disable-request-render?
finish (fn []
(reset! wasm/disable-request-render? prev-disable)
(when (fn? on-render) (on-render))
(resolve nil))]
(try
(reset! wasm/disable-request-render? true)
(let [os-canvas (js/OffscreenCanvas. width height)]
(if (wasm.api/init-canvas-context os-canvas)
(wasm.api/initialize-viewport
objects scale bounds
:background-opacity 0
:force-sync true
:on-render
(fn []
(viewer-disable-wasm-ui-overlay!)
(wasm.api/render-sync-shape object-id)
(draw-bitmap! canvas os-canvas object-id width height
(fn []
(wasm.api/clear-canvas {:lose-browser-context? false})
(reset-viewer-snapshot!)
(finish)))))
(finish)))
(catch :default e
(js/console.error "Error initializing canvas context:" e)
(finish)))))))
(defn render-to-canvas
"One-shot WASM render into `canvas` (exports, thumbnails). Serialized globally."
[objects canvas bounds scale object-id on-render]
(enqueue-wasm-render!
(fn []
(render-to-canvas* objects canvas bounds scale object-id on-render))))
(defn- render-viewer-frame*
[page-key page-objects canvas size scale object-id on-render
{:keys [include-ids clear-fills-ids] :or {clear-fills-ids #{}}}]
(p/create
(fn [resolve _reject]
(let [prev-disable @wasm/disable-request-render?
finish (fn []
(reset! wasm/disable-request-render? prev-disable)
(when (fn? on-render) (on-render))
(resolve nil))
vis-w (.-width canvas)
vis-h (.-height canvas)
dpr (wasm.api/get-dpr)
snap @viewer-snapshot
same-page? (and (some? page-key) (identical? page-key (:page-key snap)))
same-size? (and (= vis-w (:canvas-w snap))
(= vis-h (:canvas-h snap))
(= dpr (:dpr snap)))
os (:os-canvas snap)
do-render! (fn [os-canvas]
(viewer-do-render! page-objects canvas os-canvas object-id
vis-w vis-h scale size include-ids
clear-fills-ids finish))]
(reset! wasm/disable-request-render? true)
(try
(if (and same-page? (wasm.api/initialized?) os)
(do
(when-not same-size?
(wasm.api/resize-offscreen-canvas! os vis-w vis-h)
(swap! viewer-snapshot assoc :canvas-w vis-w :canvas-h vis-h :dpr dpr))
(do-render! os))
(let [os-canvas (js/OffscreenCanvas. vis-w vis-h)]
(when (wasm.api/initialized?)
(wasm.api/clear-canvas {:lose-browser-context? false}))
(if (wasm.api/init-canvas-context os-canvas)
(do
(reset! viewer-snapshot
{:os-canvas os-canvas
:page-key page-key
:canvas-w vis-w
:canvas-h vis-h
:dpr dpr})
(wasm.api/initialize-viewport
page-objects scale size
:background-opacity 0
:force-sync true
:on-render #(do-render! os-canvas)))
(finish))))
(catch :default e
(js/console.error "viewer-snapshot: render error" e)
(finish)))))))
(defn- use-fixed-scroll-sync!
[enabled? layer-ref]
(mf/use-layout-effect
(mf/deps enabled?)
(fn []
(when enabled?
(let [section (dom/get-element "viewer-section")
sync!
(fn []
(when-let [layer (mf/ref-val layer-ref)]
(dom/set-style! layer "transform"
(dm/str "translate("
(or (dom/get-h-scroll-pos section) 0) "px, "
(or (dom/get-scroll-pos section) 0) "px)"))))]
(when section
(sync!)
(let [key (events/listen section "scroll" (fn [_] (sync!)))]
#(events/unlistenByKey key))))))))
(defn- use-viewer-dpr-key
"Bump a counter when browser zoom changes devicePixelRatio so WASM canvases
are resized like the workspace viewport."
[]
(let [dpr-key (mf/use-state 0)]
(mf/use-effect
(mf/deps [])
(fn []
(webapi/on-dpr-change (fn [_] (swap! dpr-key inc)))))
@dpr-key))
(defn- use-viewer-wasm-layers!
[page-id page-objects size scale frame-id not-fixed-ref fixed-ref
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids delta dpr-key]
;; The hot-areas SVG shifts every object by `-(size + delta)` so the frame
;; `selrect` lands flush against the overlay snap side, ignoring the extra
;; padding reserved for shadows/blur/strokes. Bake the same `delta` into the
;; WASM view origin so the rendered canvas aligns with that SVG (otherwise it
;; appears offset by the shadow margin).
(let [render-size (-> size
(update :x + (:x delta 0))
(update :y + (:y delta 0)))]
(mf/use-layout-effect
(mf/deps page-id page-objects render-size scale frame-id dpr-key
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids)
(fn []
(when (get page-objects frame-id)
(->> @wasm.api/module
(p/fmap
(fn [ready?]
(when ready?
(let [not-fixed-canvas (mf/ref-val not-fixed-ref)
fixed-canvas (mf/ref-val fixed-ref)
passes
(cond-> []
not-fixed-canvas
(conj {:canvas not-fixed-canvas
:opts (cond-> {}
(seq not-fixed-include-ids)
(assoc :include-ids not-fixed-include-ids))})
(and fixed-canvas (seq fixed-include-ids))
(conj {:canvas fixed-canvas
:opts (cond-> {:include-ids fixed-include-ids}
(seq fixed-clear-fills-ids)
(assoc :clear-fills-ids fixed-clear-fills-ids))}))]
(when (seq passes)
(enqueue-wasm-render!
(fn []
(reduce (fn [chain {:keys [canvas opts]}]
(p/then chain
#(render-viewer-frame* page-id page-objects
canvas render-size scale frame-id
nil opts)))
(p/resolved nil)
passes))))))))))))))
(defn use-viewer-wasm-viewport!
"WASM render passes and fixed-scroll DOM sync for the viewer viewport."
[page-id page-objects size scale frame-id
not-fixed-ref fixed-ref fixed-scroll-layer-ref
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids delta]
(use-fixed-scroll-sync! (some? fixed-scroll-layer-ref) fixed-scroll-layer-ref)
(let [dpr-key (use-viewer-dpr-key)]
(use-viewer-wasm-layers! page-id page-objects size scale frame-id
not-fixed-ref fixed-ref
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids
delta dpr-key)))

View File

@ -27,7 +27,7 @@
[app.main.ui.nitrate.entry :as nitrate-entry]
[app.main.ui.notifications :as notifications]
[app.main.ui.onboarding.questions :refer [questions-modal]]
[app.main.ui.onboarding.team-choice :refer [onboarding-team-modal]]
[app.main.ui.onboarding.team-choice :refer [onboarding-team-modal*]]
[app.main.ui.releases :refer [release-notes-modal]]
[app.main.ui.static :as static]
[app.util.dom :as dom]
@ -238,14 +238,14 @@
#_[:& app.main.ui.releases/release-notes-modal {:version "2.5"}]
#_[:& app.main.ui.onboarding/onboarding-templates-modal]
#_[:& app.main.ui.onboarding/onboarding-modal]
#_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal]
#_[:> app.main.ui.onboarding.team-choice/onboarding-team-modal*]
(cond
show-question-modal?
[:& questions-modal]
show-team-modal?
[:& onboarding-team-modal {:go-to-team true}]
[:> onboarding-team-modal* {:go-to-team true}]
show-release-modal?
[:& release-notes-modal {:version (:main cf/version)}])
@ -272,7 +272,7 @@
[:& questions-modal]
show-team-modal?
[:& onboarding-team-modal {:go-to-team false}]
[:> onboarding-team-modal* {:go-to-team false}]
show-release-modal?
[:& release-notes-modal {:version (:main cf/version)}]))

View File

@ -11,7 +11,7 @@
[app.main.data.auth :as da]
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-page]]
[app.main.ui.auth.recovery :refer [recovery-page]]
[app.main.ui.auth.recovery :refer [recovery-page*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page]]
[app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-register*]]
[app.main.ui.icons :as deprecated-icon]
@ -69,7 +69,7 @@
[:& recovery-request-page]
:auth-recovery
[:& recovery-page {:params params}])
[:> recovery-page* {:params params}])
(when (= section :auth-register)
[:> terms-register*])]]))

View File

@ -44,8 +44,8 @@
:password (get-in @form [:clean-data :password-2])}]
(st/emit! (du/recover-profile (with-meta params mdata)))))
(mf/defc recovery-form
[{:keys [params] :as props}]
(mf/defc recovery-form*
[{:keys [params]}]
(let [form (fm/use-form :schema schema:recovery-form
:initial params)]
@ -73,13 +73,13 @@
;; --- Recovery Request Page
(mf/defc recovery-page
[{:keys [params] :as props}]
(mf/defc recovery-page*
[{:keys [params]}]
[:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title)} "Forgot your password?"]
[:div {:class (stl/css :auth-subtitle)} "Please enter your new password"]
[:hr {:class (stl/css :separator)}]
[:& recovery-form {:params params}]
[:> recovery-form* {:params params}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :go-back)}

View File

@ -557,6 +557,32 @@
(dom/stop-propagation event)
(swap! items (fn [items] (if (c/empty? items) items (pop items)))))))))
on-paste
(mf/use-fn
(fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched
(swap! form assoc-in [:touched input-name] true)
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched input-name] false))))))
on-blur
(mf/use-fn
(fn [_]
@ -590,6 +616,7 @@
:on-focus on-focus
:on-blur on-blur
:on-key-down on-key-down
:on-paste on-paste
:value @value
:on-change on-change
:placeholder (when empty? label)}]

View File

@ -12,10 +12,12 @@
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.dashboard.subscription :refer [get-subscription-type]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.hooks :as hooks]
@ -239,13 +241,15 @@
;; Calculate deletion days based on team subscription
deletion-days
(let [subscription (get team :subscription)
sub-type (get subscription :type)
sub-status (get subscription :status)
canceled? (contains? #{"canceled" "unpaid"} sub-status)]
(let [profile (mf/deref refs/profile)
subscription-type (get-subscription-type (:subscription team))
nitrate-type (get-in profile [:subscription :type])
nitrate-active? (dnt/is-valid-license? profile)]
(cond
(and (= "unlimited" sub-type) (not canceled?)) 30
(and (= "enterprise" sub-type) (not canceled?)) 90
(and nitrate-active?
(contains? #{"enterprise" "nitrate"} nitrate-type)) 90
(= subscription-type "unlimited") 30
(= subscription-type "enterprise") 90
:else 7))
on-delete-all

Some files were not shown because too many files have changed in this diff Show More