From b3d1a7aa8bffbe96e445856557254d87f15c3c78 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 08:33:46 +0200 Subject: [PATCH 01/16] :books: Add better dev tools doc to serena --- .opencode/skills/nrepl-eval/SKILL.md | 99 ++------------------- .serena/memories/critical-info.md | 19 +++- .serena/memories/tools/gh.md | 80 +++++++++++++++++ .serena/memories/tools/nrepl-eval.md | 126 +++++++++++++++++++++++++++ .serena/memories/tools/taiga.md | 44 ++++++++++ tools/nrepl-eval.mjs | 29 +++++- 6 files changed, 299 insertions(+), 98 deletions(-) create mode 100644 .serena/memories/tools/gh.md create mode 100644 .serena/memories/tools/nrepl-eval.md create mode 100644 .serena/memories/tools/taiga.md diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md index 2cf44d88c7..b4b3d76d7f 100644 --- a/.opencode/skills/nrepl-eval/SKILL.md +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -6,26 +6,20 @@ description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-ev # nREPL Eval Evaluate Clojure (or ClojureScript) code via a running nREPL server using -`tools/nrepl-eval.mjs` — a standalone CLI application. +`tools/nrepl-eval.mjs`. -Session state (defs, in-ns, etc.) persists across invocations via a stored -session ID, so you can build up state incrementally. +Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`) -## Usage +## Quick Reference -```bash -node tools/nrepl-eval.mjs [options] [] -``` - -The tool is also executable directly: ```bash ./tools/nrepl-eval.mjs [options] [] ``` -## Options - | Flag | Description | Default | |------|-------------|---------| +| `--backend` | Connect to backend nREPL (port 6064) | — | +| `--frontend` | Connect to frontend nREPL (port 3447) | — | | `-p, --port PORT` | nREPL server port | `6064` | | `-H, --host HOST` | nREPL server host | `127.0.0.1` | | `-t, --timeout MS` | Timeout in milliseconds | `120000` | @@ -33,88 +27,11 @@ The tool is also executable directly: | `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | | `-h, --help` | Show help message | — | -## When to Use +## Examples -Use this tool when you need to: - -1. **Evaluate Clojure code** during development — test functions, inspect - state, or run experiments against a running Clojure process. -2. **Verify that edited files compile** — require namespaces with `:reload` - to pick up changes. -3. **Inspect the last exception** after a failed evaluation — use `-e` to - print the error stored in `*e`. - -## Workflow - -### 1. Session management - -Sessions are persisted to `/tmp/penpot-nrepl-session--`. State -carries across calls automatically: - -```bash -./tools/nrepl-eval.mjs '(def x 42)' -./tools/nrepl-eval.mjs 'x' -# => 42 -``` - -Reset the session to start fresh: - -```bash -./tools/nrepl-eval.mjs --reset-session '(def x 0)' -``` - -### 2. Evaluate code - -**Single expression (inline) — uses default port 6064:** ```bash ./tools/nrepl-eval.mjs '(+ 1 2 3)' -``` - -**Multiple expressions via heredoc (recommended — avoids escaping issues):** -```bash -./tools/nrepl-eval.mjs <<'EOF' -(def x 10) -(+ x 20) -EOF -``` - -**Override with a different port:** -```bash -./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' -``` - -### 3. Inspect last exception - -After code throws an error, retrieve the full exception details: - -```bash +./tools/nrepl-eval.mjs --backend '(+ 1 2 3)' +./tools/nrepl-eval.mjs --frontend '(js/alert "hi")' ./tools/nrepl-eval.mjs -e ``` - -## Common Patterns - -**Require a namespace with reload:** -```bash -./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" -``` - -**Test a function:** -```bash -./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" -``` - -**Long-running operation with custom timeout:** -```bash -./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" -``` - -## Key Principles - -- **Default port is 6064** — just pass code directly, no `-p` needed when - your nREPL server is on 6064. Use `-p ` for a different port. -- **Always use `:reload`** when requiring namespaces to pick up file changes. -- **Session is reused** across invocations — defs, in-ns, and var bindings - persist. Use `--reset-session` to clear. -- **Do not start any server** — the tool connects to an existing nREPL - server, it is not the agent's responsibility to start the nREPL server - (assume the server is already running on the specified port). diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index d0f83b656b..1027829bcf 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -48,13 +48,24 @@ module. You can read it from `mem:/core` When working on devenv startup, compose layout, instance config (`defaults.env`), tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s `*-devenv` commands, read `mem:devenv/core`. -- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval), - `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), - `psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and - `taiga.py` / `gh.py` (issue management helpers). - `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. - `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. +# Dev tools + +- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL. + Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases. + See `mem:tools/nrepl-eval`. +- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files + and reformat with cljfmt. Run before lint checks when LLM edits break parens. + See `mem:tools/paren-repair`. +- `tools/psql` — PostgreSQL client wrapper with devenv defaults. + Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`. +- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the + Penpot Taiga project without authentication. See `mem:tools/taiga`. +- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR + details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`. + # Dependency graph `frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can diff --git a/.serena/memories/tools/gh.md b/.serena/memories/tools/gh.md new file mode 100644 index 0000000000..3f74b82b66 --- /dev/null +++ b/.serena/memories/tools/gh.md @@ -0,0 +1,80 @@ +# GitHub operations helper + +`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub +repository via GraphQL and REST APIs through the authenticated `gh` CLI. + +## When to use + +- Listing issues in a milestone (for changelog generation). +- Finding issues with no milestone. +- Fetching PR details by number or by milestone. +- Comparing milestone issues against CHANGES.md to find missing entries. + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`). +- Python 3.8+. + +## Subcommands + +### `issues` + +List issues in a milestone, with filtering by state, labels, and project status. + +```bash +# Closed issues in a milestone (default) +python3 tools/gh.py issues "2.16.0" + +# All issues in a milestone +python3 tools/gh.py issues "2.16.0" --state all + +# Issues with no milestone +python3 tools/gh.py issues none +python3 tools/gh.py issues none --state open + +# Filter by label (include only) +python3 tools/gh.py issues "2.16.0" --label "bug" +python3 tools/gh.py issues "2.16.0" --label "bug,regression" + +# Exclude by label +python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" + +# Show only issues NOT yet in CHANGES.md +python3 tools/gh.py issues "2.16.0" --compare CHANGES.md +``` + +**Default filters** (override with flags): +- Issues with type "Task" are excluded (`--include-tasks` to keep them). +- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them). + +**Output**: JSON array to stdout; progress to stderr. + +### `prs` + +Fetch PR details by number or by milestone. + +```bash +# Fetch specific PRs +python3 tools/gh.py prs 9179 9204 9311 + +# Read PR numbers from file +python3 tools/gh.py prs --file prs.txt + +# Read PR numbers from stdin +cat prs.txt | python3 tools/gh.py prs --stdin + +# All PRs in a milestone (default: merged only) +python3 tools/gh.py prs --milestone "2.16.0" + +# All PRs in a milestone (all states) +python3 tools/gh.py prs --milestone "2.16.0" --state all +``` + +**Output**: JSON array to stdout; progress to stderr. + +## Key principles + +- All output is JSON — pipe into `jq` or other tools for further processing. +- Milestone lookup is by exact title match. +- `issues` subcommand auto-paginates (100 items per page). +- `prs` subcommand batches PR number lookups (50 per GraphQL query). diff --git a/.serena/memories/tools/nrepl-eval.md b/.serena/memories/tools/nrepl-eval.md new file mode 100644 index 0000000000..f5ec3ab994 --- /dev/null +++ b/.serena/memories/tools/nrepl-eval.md @@ -0,0 +1,126 @@ +# nREPL Eval + +Evaluate Clojure (or ClojureScript) code via a running nREPL server using +`tools/nrepl-eval.mjs` — a standalone CLI application. + +Session state (defs, in-ns, etc.) persists across invocations via a stored +session ID, so you can build up state incrementally. + +## Usage + +```bash +node tools/nrepl-eval.mjs [options] [] +# or +./tools/nrepl-eval.mjs [options] [] +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--backend` | Connect to backend nREPL (port 6064) | — | +| `--frontend` | Connect to frontend nREPL (port 3447) | — | +| `-p, --port PORT` | nREPL server port | `6064` | +| `-H, --host HOST` | nREPL server host | `127.0.0.1` | +| `-t, --timeout MS` | Timeout in milliseconds | `120000` | +| `--reset-session` | Discard stored session and start fresh | — | +| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | +| `-h, --help` | Show help message | — | + +- `--backend` and `--frontend` are mutually exclusive. +- Explicit `--port` is overridden when `--backend`/`--frontend` is used. + +## When to Use + +1. **Evaluate Clojure code** during development — test functions, inspect + state, or run experiments against a running Clojure process. +2. **Verify that edited files compile** — require namespaces with `:reload` + to pick up changes. +3. **Inspect the last exception** after a failed evaluation — use `-e` to + print the error stored in `*e`. + +## Workflow + +### Session management + +Sessions are persisted to `/tmp/penpot-nrepl-session--`. State +carries across calls automatically: + +```bash +./tools/nrepl-eval.mjs '(def x 42)' +./tools/nrepl-eval.mjs 'x' +# => 42 +``` + +Reset the session to start fresh: + +```bash +./tools/nrepl-eval.mjs --reset-session '(def x 0)' +``` + +### Evaluate code + +**Single expression (inline) — uses default port 6064:** +```bash +./tools/nrepl-eval.mjs '(+ 1 2 3)' +``` + +**Backend nREPL (explicit):** +```bash +./tools/nrepl-eval.mjs --backend '(+ 1 2 3)' +``` + +**Frontend nREPL:** +```bash +./tools/nrepl-eval.mjs --frontend '(js/alert "hi")' +``` + +**Multiple expressions via heredoc (recommended — avoids escaping issues):** +```bash +./tools/nrepl-eval.mjs <<'EOF' +(def x 10) +(+ x 20) +EOF +``` + +**Override with a different port:** +```bash +./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' +``` + +### Inspect last exception + +After code throws an error, retrieve the full exception details: + +```bash +./tools/nrepl-eval.mjs -e +``` + +## Common Patterns + +**Require a namespace with reload:** +```bash +./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" +``` + +**Test a function:** +```bash +./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" +``` + +**Long-running operation with custom timeout:** +```bash +./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" +``` + +## Key Principles + +- **Default port is 6064** — just pass code directly, no `-p` needed when + your nREPL server is on 6064. Use `--backend` (6064) or `--frontend` (3447) + as quick aliases. Use `-p ` for any other port. +- **Always use `:reload`** when requiring namespaces to pick up file changes. +- **Session is reused** across invocations — defs, in-ns, and var bindings + persist. Use `--reset-session` to clear. +- **Do not start any server** — the tool connects to an existing nREPL + server, it is not the agent's responsibility to start the nREPL server + (assume the server is already running on the specified port). diff --git a/.serena/memories/tools/taiga.md b/.serena/memories/tools/taiga.md new file mode 100644 index 0000000000..b4e623e3e1 --- /dev/null +++ b/.serena/memories/tools/taiga.md @@ -0,0 +1,44 @@ +# Taiga API client + +`tools/taiga.py` fetches public issues, user stories, and tasks from the +Penpot Taiga project (id 345963) without authentication. + +## When to use + +- Fetching details of a Taiga issue, user story, or task by URL or ref number. +- Inspecting status, assignee, tags, description, and other metadata. +- Piping structured JSON into other scripts (with `--json`). + +## How to use + +```bash +# Fetch by full Taiga URL +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# Fetch by type and ref number +python3 tools/taiga.py issue 13714 +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Output raw JSON instead of formatted summary +python3 tools/taiga.py --json issue 13714 +python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 +``` + +## Supported types + +| Type | Description | +|------|-------------| +| `issue` | Bug reports, feature requests | +| `us` | User stories | +| `task` | Implementation tasks | + +## Output + +Default output is a formatted summary with title, status, assignee, author, +tags, URL, and description. Use `--json` for the raw API response. + +## Prerequisites + +- Python 3.8+ (standard library only, no dependencies). +- Network access to `api.taiga.io`. diff --git a/tools/nrepl-eval.mjs b/tools/nrepl-eval.mjs index 45da2445de..8a10905305 100755 --- a/tools/nrepl-eval.mjs +++ b/tools/nrepl-eval.mjs @@ -128,18 +128,24 @@ function formatEvalMessages(messages) { function parseArgs(argv) { const args = { - port: 6064, + port: null, host: "127.0.0.1", timeout: DEFAULT_TIMEOUT, help: false, resetSession: false, lastError: false, + backend: false, + frontend: false, code: null, }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "-p" || a === "--port") { + if (a === "--backend") { + args.backend = true; + } else if (a === "--frontend") { + args.frontend = true; + } else if (a === "-p" || a === "--port") { const val = argv[++i]; if (val === undefined) { console.error("Error: --port requires a value."); process.exit(1); } args.port = parseInt(val, 10); @@ -166,6 +172,19 @@ function parseArgs(argv) { } } + if (args.backend && args.frontend) { + console.error("Error: --backend and --frontend are mutually exclusive."); + process.exit(1); + } + + if (args.backend) { + args.port = 6064; + } else if (args.frontend) { + args.port = 3447; + } else if (args.port === null) { + args.port = 6064; + } + return args; } @@ -177,7 +196,9 @@ Evaluate Clojure code via a running nREPL server. Session state (defs, in-ns) persists across invocations via a stored session ID. Options: - -p, --port PORT nREPL port (default: 6064) + --backend Connect to backend nREPL on port 6064 (default) + --frontend Connect to frontend nREPL on port 3447 + -p, --port PORT nREPL port (default: 6064; overridden by --backend/--frontend) -H, --host HOST nREPL host (default: 127.0.0.1) -t, --timeout MILLISECONDS Timeout in milliseconds (default: 120000) --reset-session Discard stored session and start fresh @@ -187,6 +208,8 @@ Options: Examples: ${bin} '(def x 42)' ${bin} 'x' + ${bin} --backend '(+ 1 2 3)' + ${bin} --frontend '(js/alert "hi")' ${bin} --reset-session '(def x 0)' ${bin} --last-error ${bin} <<'EOF' From 455c7f5cae1a0d9e0b47a4bb23b5de62881c52a1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 08:48:00 +0200 Subject: [PATCH 02/16] :books: Update serena doc related to creating issues --- .serena/memories/workflow/creating-issues.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 9f3831b165..9aff0b50c1 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`. | Field | Rule | |-------|------| -| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) | +| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) | | **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. | @@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/` | Docs | `IT_kwDOAcyBPM4B_IQz` | **Map:** -- `bug` label → Bug -- `enhancement` label → Enhancement +- Bug report (steps to reproduce, expected vs. actual) → Bug +- Enhancement / new feature → Enhancement - Feature/epic → Feature - Docs → Docs - None of the above → Task From 8fcd8a63b46fe67ccd31466799fa5c1225a59241 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 09:25:19 +0200 Subject: [PATCH 03/16] :arrow_up: Update opencode dependency on devenv --- docker/devenv/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1b854a48cb..1fd9a7b382 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.17.13 +ENV OPENCODE_VERSION=1.17.16 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From 8e9df0c5153a8cd9fa83192d87cd72fcfb6b72dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 09:25:59 +0200 Subject: [PATCH 04/16] :sparkles: Add insert-multi chunked variant to app.db --- backend/src/app/db.clj | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index c4e432b225..f10b0089a0 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -331,7 +331,10 @@ This expands to a single SQL statement with placeholders for every value being inserted. For large data sets, this may exceed the limit - of sql string size and/or number of parameters." + of sql string size and/or number of parameters. + + See `insert-many-chunked!` for a safe alternative that automatically + partitions rows to stay within the parameter limit." [ds table cols rows & {:as opts}] (let [conn (get-connectable ds) sql (sql/insert-many table cols rows opts) @@ -341,6 +344,24 @@ opts (update opts :return-keys boolean)] (jdbc/execute! conn sql opts))) +(def ^:private default-max-params + "PostgreSQL PreparedStatement parameter limit." + 65535) + +(defn insert-many-chunked! + "Like `insert-many!` but partitions rows into chunks that stay within + PostgreSQL's 65,535 PreparedStatement parameter limit. + + The chunk size is computed as `floor(max-params / num-columns)`, + so callers do not need to calculate it. All chunks execute within + the same transaction when called inside `tx-run!`." + [ds table cols rows & {:keys [max-params] :as opts + :or {max-params default-max-params}}] + (let [chunk-size (quot max-params (count cols)) + opts (dissoc opts :max-params)] + (doseq [chunk (partition-all chunk-size rows)] + (apply insert-many! ds table cols chunk (mapcat identity opts))))) + (defn update! "A helper that build an UPDATE SQL statement and executes it. From f2460eee2957383d527fbed684cc890714dba6b7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 10:59:40 +0200 Subject: [PATCH 05/16] :bug: Fix Draft.js selection offset DOMException on text editor re-render (#10608) Clamp selection offset to node length in setDraftEditorSelection to prevent DOMException when Draft.js SelectionState offset exceeds the actual DOM text node length. This can happen during spellcheck, IME composition, or race conditions during React re-renders. Updates @penpot/draft-js to commit 09a33e0a which includes the fix in addPointToSelection and addFocusToSelection. Fixes #10607 AI-assisted-by: mimo-v2.5-pro --- frontend/packages/draft-js/package.json | 2 +- frontend/packages/draft-js/pnpm-lock.yaml | 449 ---------------------- frontend/pnpm-lock.yaml | 10 +- package.json | 2 +- 4 files changed, 7 insertions(+), 456 deletions(-) delete mode 100644 frontend/packages/draft-js/pnpm-lock.yaml diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 8ad22449d7..ec153b2eac 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -8,7 +8,7 @@ "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0", + "draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35", "immutable": "^5.1.9" }, "peerDependencies": { diff --git a/frontend/packages/draft-js/pnpm-lock.yaml b/frontend/packages/draft-js/pnpm-lock.yaml deleted file mode 100644 index 6efab568fd..0000000000 --- a/frontend/packages/draft-js/pnpm-lock.yaml +++ /dev/null @@ -1,449 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - immutable: - specifier: ^5.1.4 - version: 5.1.4 - react: - specifier: '>=0.17.0' - version: 19.2.3 - react-dom: - specifier: '>=0.17.0' - version: 19.2.3(react@19.2.3) - devDependencies: - esbuild: - specifier: ^0.27.2 - version: 0.27.2 - -packages: - - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} - version: 0.11.7 - peerDependencies: - react: '>=0.14.0' - react-dom: '>=0.14.0' - - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - - fbjs-css-vars@1.0.2: - resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} - - fbjs@3.0.5: - resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} - - immutable@3.7.6: - resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} - engines: {node: '>=0.8.0'} - - immutable@5.1.4: - resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 - - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - ua-parser-js@1.0.41: - resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} - hasBin: true - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - -snapshots: - - '@esbuild/aix-ppc64@0.27.2': - optional: true - - '@esbuild/android-arm64@0.27.2': - optional: true - - '@esbuild/android-arm@0.27.2': - optional: true - - '@esbuild/android-x64@0.27.2': - optional: true - - '@esbuild/darwin-arm64@0.27.2': - optional: true - - '@esbuild/darwin-x64@0.27.2': - optional: true - - '@esbuild/freebsd-arm64@0.27.2': - optional: true - - '@esbuild/freebsd-x64@0.27.2': - optional: true - - '@esbuild/linux-arm64@0.27.2': - optional: true - - '@esbuild/linux-arm@0.27.2': - optional: true - - '@esbuild/linux-ia32@0.27.2': - optional: true - - '@esbuild/linux-loong64@0.27.2': - optional: true - - '@esbuild/linux-mips64el@0.27.2': - optional: true - - '@esbuild/linux-ppc64@0.27.2': - optional: true - - '@esbuild/linux-riscv64@0.27.2': - optional: true - - '@esbuild/linux-s390x@0.27.2': - optional: true - - '@esbuild/linux-x64@0.27.2': - optional: true - - '@esbuild/netbsd-arm64@0.27.2': - optional: true - - '@esbuild/netbsd-x64@0.27.2': - optional: true - - '@esbuild/openbsd-arm64@0.27.2': - optional: true - - '@esbuild/openbsd-x64@0.27.2': - optional: true - - '@esbuild/openharmony-arm64@0.27.2': - optional: true - - '@esbuild/sunos-x64@0.27.2': - optional: true - - '@esbuild/win32-arm64@0.27.2': - optional: true - - '@esbuild/win32-ia32@0.27.2': - optional: true - - '@esbuild/win32-x64@0.27.2': - optional: true - - asap@2.0.6: {} - - cross-fetch@3.2.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - fbjs: 3.0.5 - immutable: 3.7.6 - object-assign: 4.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - transitivePeerDependencies: - - encoding - - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - - fbjs-css-vars@1.0.2: {} - - fbjs@3.0.5: - dependencies: - cross-fetch: 3.2.0 - fbjs-css-vars: 1.0.2 - loose-envify: 1.4.0 - object-assign: 4.1.1 - promise: 7.3.1 - setimmediate: 1.0.5 - ua-parser-js: 1.0.41 - transitivePeerDependencies: - - encoding - - immutable@3.7.6: {} - - immutable@5.1.4: {} - - js-tokens@4.0.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - object-assign@4.1.1: {} - - promise@7.3.1: - dependencies: - asap: 2.0.6 - - react-dom@19.2.3(react@19.2.3): - dependencies: - react: 19.2.3 - scheduler: 0.27.0 - - react@19.2.3: {} - - scheduler@0.27.0: {} - - setimmediate@1.0.5: {} - - tr46@0.0.3: {} - - ua-parser-js@1.0.41: {} - - webidl-conversions@3.0.1: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c11f5736a6..e7ee6eda1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -263,8 +263,8 @@ importers: packages/draft-js: dependencies: draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35 + version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) immutable: specifier: ^5.1.9 version: 5.1.9 @@ -2886,8 +2886,8 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35: + resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35} version: 0.11.7 peerDependencies: react: '>=0.14.0' @@ -8117,7 +8117,7 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: fbjs: 3.0.5(encoding@0.1.13) immutable: 3.8.3 diff --git a/package.json b/package.json index 77800eaca6..1454dbd2bc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" From 41ebb8e80b0d1f608da01d9c8ffe2351634771f8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 11:00:18 +0200 Subject: [PATCH 06/16] :bug: Fix crash when converting SVG-raw shape to path (#10613) Guard the content-to-PathData coercion on whether stp/convert-to-path actually produced a new value, so SVG-raw shapes (whose :content is a hiccup map) pass through unchanged instead of crashing. Closes #10612 AI-assisted-by: deepseek-v4-pro --- common/src/app/common/types/path.cljc | 6 ++++-- common/test/common_tests/types/path_data_test.cljc | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 60e736914b..2b1188682f 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -392,7 +392,9 @@ ([shape] (convert-to-path shape {})) ([shape objects] - (-> (stp/convert-to-path shape objects) - (update :content impl/path-data)))) + (let [shape' (stp/convert-to-path shape objects)] + (if (identical? shape shape') + shape' + (update shape' :content impl/path-data))))) (dm/export impl/decode-segments) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index f56d61a539..714270cb62 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1370,6 +1370,15 @@ ;; A path shape stays a path shape unchanged (t/is (= :path (:type result))))) +(t/deftest shape-to-path-svg-raw-does-not-throw + (let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0 + :selrect (make-selrect 0.0 0.0 100.0 50.0) + :content {:tag :text :attrs {:style {}} + :content [{:tag :tspan :attrs {} :content ["x"]}]}} + result (path/convert-to-path shape {})] + (t/is (= :svg-raw (:type result))) + (t/is (some? (:content result))))) + (t/deftest shape-to-path-rect-with-radius (let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0 :r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0 From 47a3158602099c55f84bb820984d93084c00fefe Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 11:01:08 +0200 Subject: [PATCH 07/16] :bug: Fix component variant panel crash with mismatched property counts (#10616) Use `get` instead of `nth` to avoid index-out-of-bounds when a selected component copy has fewer variant properties than the first component in the selection. Closes #10615 AI-assisted-by: deepseek-v4-pro --- .../app/main/ui/workspace/sidebar/options/menus/component.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 536c087b36..2851124260 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -522,7 +522,7 @@ [:* [:div {:class (stl/css :variant-property-list)} (for [[pos prop] (map-indexed vector props-first)] - (let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties) + (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties) options (get-options (:name prop)) boolean-pair (ctv/find-boolean-pair (mapv :id options)) options (cond-> options From 28fd798c5238a26310f2e0325d4725084c2c5685 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 11:03:06 +0200 Subject: [PATCH 08/16] :bug: Fix crash with degenerate selrect in text pipeline (#10618) Guard remaining text pipeline locations that accessed :selrect directly against nil/zero-dimension selrects by using safe-size-rect, which provides a 4-level fallback chain (selrect -> points -> shape fields -> empty 0.01x0.01 rect). Fixes: - fix-position in viewport_texts_html.cljs: replaced dm/get-prop :selrect with ctm/safe-size-rect for both old and new shape - assoc-position-data in modifiers.cljs: replaced (:selrect ...) with ctm/safe-size-rect for delta computation - change-orientation-modifiers in modifiers.cljc: replaced raw :selrect access with safe-size-rect for scale and origin computation Closes #10617 AI-assisted-by: mimo-v2.5-pro --- common/src/app/common/types/modifiers.cljc | 4 +-- .../common_tests/types/modifiers_test.cljc | 32 ++++++++++++++++++ .../app/main/data/workspace/modifiers.cljs | 4 +-- .../shapes/text/viewport_texts_html.cljs | 4 +-- .../data/workspace_texts_test.cljs | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index dbc0b52296..34a81365f1 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -500,9 +500,9 @@ shape-transform (:transform shape) shape-transform-inv (:transform-inverse shape) shape-center (gco/shape->center shape) - {sr-width :width sr-height :height} (:selrect shape) + {sr-width :width sr-height :height} (safe-size-rect shape) - origin (cond-> (gpt/point (:selrect shape)) + origin (cond-> (gpt/point (safe-size-rect shape)) (some? shape-transform) (gmt/transform-point-center shape-center shape-transform)) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 2cfb196fa8..405da89935 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -657,3 +657,35 @@ mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) result (ctm/apply-structure-modifiers shape mods)] (t/is (mth/close? 45.0 (:rotation result)))))) + +;; ─── change-orientation-modifiers — degenerate selrect ──────────────────────── + +(defn- make-degenerate-shape + "Build a shape whose selrect has zero width/height, simulating a shape + decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)." + [x y selrect-width selrect-height] + (let [shape (make-shape x y 100 50)] + (assoc shape :selrect (grc/map->Rect {:x x :y y + :width selrect-width + :height selrect-height + :x1 x :y1 y + :x2 (+ x selrect-width) + :y2 (+ y selrect-height)})))) + +(t/deftest change-orientation-zero-width-selrect-does-not-throw + (t/testing "orientation change on a shape with zero selrect width does not throw" + (let [shape (make-degenerate-shape 0 0 0 50) + mods (ctm/change-orientation-modifiers shape :horiz)] + (t/is (some? mods))))) + +(t/deftest change-orientation-zero-height-selrect-does-not-throw + (t/testing "orientation change on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-shape 0 0 100 0) + mods (ctm/change-orientation-modifiers shape :vert)] + (t/is (some? mods))))) + +(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw + (t/testing "orientation change on a fully degenerate selrect does not throw" + (let [shape (make-degenerate-shape 0 0 0 0) + mods (ctm/change-orientation-modifiers shape :horiz)] + (t/is (some? mods))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 96a1605704..b5696414c8 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -256,8 +256,8 @@ (defn assoc-position-data [shape position-data old-shape] - (let [deltav (gpt/to-vec (gpt/point (:selrect old-shape)) - (gpt/point (:selrect shape))) + (let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape)) + (gpt/point (ctm/safe-size-rect shape))) position-data (-> position-data (gsh/move-position-data deltav))] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index a22b82f46a..ae6f49f688 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -35,8 +35,8 @@ (if-let [modifiers (:modifiers shape)] (let [shape' (gsh/transform-shape shape modifiers) - old-sr (dm/get-prop shape :selrect) - new-sr (dm/get-prop shape' :selrect) + old-sr (ctm/safe-size-rect shape) + new-sr (ctm/safe-size-rect shape') ;; We need to remove the movement because the dynamic modifiers will have move it deltav (gpt/to-vec (gpt/point new-sr) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 15db703ef8..ee438672c5 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -9,9 +9,11 @@ [app.common.geom.rect :as grc] [app.common.test-helpers.files :as cthf] [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.main.data.workspace.texts :as dwt] + [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths])) @@ -374,3 +376,34 @@ "exactly one typography was added") (t/is (= "0.1" (:letter-spacing (first typographies))) "float letter-spacing is normalised to 2-decimal string"))))))) + +;; --------------------------------------------------------------------------- +;; Tests: fix-position with degenerate selrect +;; --------------------------------------------------------------------------- + +(t/deftest fix-position-zero-width-selrect-does-not-throw + (t/testing "fix-position on a shape with zero selrect width does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest fix-position-zero-height-selrect-does-not-throw + (t/testing "fix-position on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw + (t/testing "fix-position on a fully degenerate selrect does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) From 64b0bff7bd1590c6c56ec786fe16ccdd1e61bf85 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Jul 2026 13:12:41 +0200 Subject: [PATCH 09/16] :books: Update serena creating-prs workflow documentation --- .serena/memories/workflow/creating-prs.md | 35 +++++++++++ tools/detect-target-branch | 74 +++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100755 tools/detect-target-branch diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 4c5e75c212..204fd49c76 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -2,6 +2,20 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `/` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). +## Target Branch + +Auto-detect the base branch with `tools/detect-target-branch`: + +```bash +TARGET=$(tools/detect-target-branch) +``` + +This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails. + +## Metadata + +Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project. + ## Title Format PR titles follow commit title conventions: @@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. - Follow `mem:workflow/creating-commits` for commits - Run the focused tests/lints appropriate to touched modules. - Do not force-push during review unless the maintainer workflow explicitly asks for it. +- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`. + +## Creating the PR + +```bash +cat > /tmp/pr-body.md << 'PR_BODY' + +PR_BODY + +TARGET=$(tools/detect-target-branch) + +gh pr create \ + --repo penpot/penpot \ + --base "$TARGET" \ + --head \ + --title "" \ + --project "Main" \ + --body-file /tmp/pr-body.md + +rm -f /tmp/pr-body.md +``` diff --git a/tools/detect-target-branch b/tools/detect-target-branch new file mode 100755 index 0000000000..5d3d584552 --- /dev/null +++ b/tools/detect-target-branch @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# detect-target-branch +# +# Determines the target branch for a PR of the current branch. +# +# Uses git name-rev to find the nearest ref (branch/tag) that is an ancestor of +# HEAD, falling back to a graph walk if name-rev returns the current branch. +# +# Pure local — no remote, no SSH, no network. Works even when the current +# branch shares its tip commit with the target (not yet diverged). +# +# Usage: +# tools/detect-target-branch # print branch name +# tools/detect-target-branch --verbose # print "branch~N" +# +# Exit status: +# 0 — target found +# 1 — no target found (orphan commit, no other branches) + +verbose=false + +for arg do + case "$arg" in + --verbose|-v) verbose=true ;; + --name-only) ;; + --help|-h) + sed -n '/^#/,/^$/p' "$0" | sed 's/^# //; s/^#$//' + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + echo "Usage: $(basename "$0") [--verbose|-v] [--name-only] [--help|-h]" >&2 + exit 1 + ;; + esac +done + +current=$(git rev-parse --abbrev-ref HEAD) + +# Use name-rev to find the nearest ref — it has smart tie-breaking +raw=$(git name-rev --name-only HEAD 2>/dev/null) +target=${raw%%~[0-9]*} # strip ~N distance suffix + +# If name-rev returned the current branch itself, walk backwards +if [ "$target" = "$current" ] || [ -z "$target" ]; then + target=$(git rev-list HEAD | while read commit; do + branch=$(git branch --points-at="$commit" --format='%(refname:short)' 2>/dev/null \ + | grep -v "^$current$" \ + | head -1 || true) + if [ -n "$branch" ]; then + echo "$branch" + break + fi + done || true) + + if [ -z "$target" ]; then + echo "error: unable to determine target branch" >&2 + exit 1 + fi +fi + +if [ "$verbose" = true ]; then + target_commit=$(git rev-parse "$target" 2>/dev/null || true) + if [ -n "$target_commit" ]; then + distance=$(git rev-list --count HEAD ^"$target_commit" 2>/dev/null || echo "0") + echo "${target}~${distance}" + else + echo "${target}~?" + fi +else + echo "$target" +fi From d6c50cc40b0439b4d0ed8f1abf1991e985feabde Mon Sep 17 00:00:00 2001 From: Alonso Torres <alonso.torres@kaleidos.net> Date: Thu, 9 Jul 2026 18:00:26 +0200 Subject: [PATCH 10/16] :bug: Fix problems with plugins api setPluginData (#10632) --- frontend/src/app/plugins/library.cljs | 6 ++ frontend/src/app/plugins/page.cljs | 4 +- plugins/CHANGELOG.md | 2 + .../src/tests/library.test.ts | 41 ++++++++++ .../src/tests/plugin-data.test.ts | 76 +++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index b97343ec20..9fb646f205 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1059,6 +1059,9 @@ :setPluginData (fn [key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) + (not (string? key)) (u/not-valid plugin-id :setPluginData-key key) @@ -1092,6 +1095,9 @@ :setSharedPluginData (fn [namespace key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) + (not (string? namespace)) (u/not-valid plugin-id :setSharedPluginData-namespace namespace) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 76bc45a832..e668bc8756 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -272,13 +272,13 @@ (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) :getSharedPluginDataKeys - (fn [self namespace] + (fn [namespace] (cond (not (string? namespace)) (u/not-valid plugin-id :page-plugin-data-namespace namespace) :else - (let [page (u/proxy->page self)] + (let [page (u/locate-page file-id id)] (apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)])))))) :openPage diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 00095c38b5..80dee3f1c9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -52,6 +52,8 @@ - **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires. - **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`). - **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error). +- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument. +- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited. ## 1.4.2 (2026-01-21) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 5f16958282..7d3a8ccf0a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -34,6 +34,24 @@ describe('Library', () => { expect(Array.isArray(ctx.penpot.library.connected)).toBe(true); }); + // The Library object itself carries plugin data (it extends PluginData), + // stored on the underlying file. Exercised on the local library; the + // connected-library case (writing to a shared library's file/assets) shares + // the same code path but can't be fixtured here (connecting a library hangs). + test('local library stores plugin data', (ctx) => { + const lib = ctx.penpot.library.local; + lib.setPluginData('k', 'v'); + expect(lib.getPluginData('k')).toBe('v'); + expect(lib.getPluginDataKeys()).toContain('k'); + }); + + test('local library stores shared plugin data', (ctx) => { + const lib = ctx.penpot.library.local; + lib.setSharedPluginData('ns', 'k', 'v'); + expect(lib.getSharedPluginData('ns', 'k')).toBe('v'); + expect(lib.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('library elements expose a libraryId', (ctx) => { const color = ctx.penpot.library.local.createColor(); expect(typeof color.libraryId).toBe('string'); @@ -136,6 +154,13 @@ describe('Library', () => { expect(color.getPluginData('k')).toBe('v'); expect(color.getPluginDataKeys()).toContain('k'); }); + + test('color shared plugin data round-trips', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.setSharedPluginData('ns', 'k', 'v'); + expect(color.getSharedPluginData('ns', 'k')).toBe('v'); + expect(color.getSharedPluginDataKeys('ns')).toContain('k'); + }); }); describe('Typographies', () => { @@ -170,6 +195,13 @@ describe('Library', () => { expect(typo.getPluginDataKeys()).toContain('k'); }); + test('typography shared plugin data round-trips', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.setSharedPluginData('ns', 'k', 'v'); + expect(typo.getSharedPluginData('ns', 'k')).toBe('v'); + expect(typo.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('typography fontFamily and fontId round-trip', (ctx) => { const typo = ctx.penpot.library.local.createTypography(); expect(typeof typo.fontFamily).toBe('string'); @@ -256,6 +288,15 @@ describe('Library', () => { expect(comp.getPluginDataKeys()).toContain('k'); }); + test('component shared plugin data round-trips', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.setSharedPluginData('ns', 'k', 'v'); + expect(comp.getSharedPluginData('ns', 'k')).toBe('v'); + expect(comp.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('component instance and mainInstance return shapes', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts index 254717e63e..5564b2e3f1 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -26,6 +26,41 @@ describe('Plugin data', () => { if (file) { file.setPluginData('fileKey', 'fileValue'); expect(file.getPluginData('fileKey')).toBe('fileValue'); + expect(file.getPluginDataKeys()).toContain('fileKey'); + } + }); + + test('shared plugin data round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue'); + expect(file.getSharedPluginData('ns', 'fileShared')).toBe( + 'fileSharedValue', + ); + expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared'); + } + }); + + test('plugin data round-trips on a page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + page.setPluginData('pageKey', 'pageValue'); + expect(page.getPluginData('pageKey')).toBe('pageValue'); + expect(page.getPluginDataKeys()).toContain('pageKey'); + } + }); + + test('shared plugin data round-trips on a page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue'); + expect(page.getSharedPluginData('ns', 'pageShared')).toBe( + 'pageSharedValue', + ); + expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared'); } }); @@ -45,6 +80,47 @@ describe('Plugin data', () => { expect(rect.getPluginDataKeys()).toContain(''); }); + // Keys are opaque strings: no case normalization is applied. A camelCase key + // and its kebab-case spelling are distinct entries that each round-trip + // independently. Pins that behaviour so a future key-normalization regression + // (reported as camelCase keys "behaving incorrectly") would be caught here. + test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('myKey', 'camel'); + rect.setPluginData('my-key', 'kebab'); + expect(rect.getPluginData('myKey')).toBe('camel'); + expect(rect.getPluginData('my-key')).toBe('kebab'); + const keys = rect.getPluginDataKeys(); + expect(keys).toContain('myKey'); + expect(keys).toContain('my-key'); + }); + + // Same guarantee at the file-data storage location (a distinct code path from + // shape/page objects), covering a camelCase key on the file itself. + test('a camelCase key round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setPluginData('camelCaseFileKey', 'value'); + expect(file.getPluginData('camelCaseFileKey')).toBe('value'); + expect(file.getPluginDataKeys()).toContain('camelCaseFileKey'); + } + }); + + // camelCase is likewise preserved in the shared namespace and the shared key. + test('camelCase shared namespace and key round-trip', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setSharedPluginData('myNamespace', 'myKey', 'camel'); + rect.setSharedPluginData('myNamespace', 'my-key', 'kebab'); + expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel'); + expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab'); + const keys = rect.getSharedPluginDataKeys('myNamespace'); + expect(keys).toContain('myKey'); + expect(keys).toContain('my-key'); + }); + test('setPluginData with a non-string value throws', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); From 3400d6afbb612ae01863fc28fbe9b990b80de4ee Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 18:04:05 +0200 Subject: [PATCH 11/16] :bug: Fix too much recursion when clicking shape in comments mode (#10622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix too much recursion when clicking shape in comments mode Remove `deselect-all` from `handle-interrupt` in comments mode. The `select-shape` event emits `:interrupt` which the comments stream watcher routes to `handle-interrupt`. In comments mode, calling `deselect-all` cleared the selection and emitted a competing `rt/nav`, creating a synchronous recursion cycle in the potok store that overflowed the JS call stack. Fixes #10620 AI-assisted-by: opencode * :bug: Add unit tests for comments handle-interrupt Make `handle-interrupt` public (defn- → defn) and add 4 tests covering each branch: draft thread, open thread, comments mode, and noop. AI-assisted-by: opencode --- .../src/app/main/data/workspace/comments.cljs | 6 +- .../data/workspace_comments_test.cljs | 84 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_comments_test.cljs diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 968cc8194e..fc9911feca 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -24,7 +24,6 @@ [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.layout :as dwlo] - [app.main.data.workspace.selection :as dws] [app.main.data.workspace.zoom :as dwz] [app.main.repo :as rp] [app.main.router :as rt] @@ -71,7 +70,7 @@ (rx/take-until stopper-s)))))) -(defn- handle-interrupt +(defn handle-interrupt [] (ptk/reify ::handle-interrupt ptk/WatchEvent @@ -85,8 +84,7 @@ ;; tool is active. When comments are merely visible during design, ;; `select-shape` emits `:interrupt` and this would otherwise wipe ;; the freshly selected shape, breaking click selection. - comments-mode? (rx/of (dwe/clear-edition-mode) - (dws/deselect-all true)) + comments-mode? (rx/of (dwe/clear-edition-mode)) :else (rx/empty)))))) ;; Event responsible of the what should be executed when user clicked diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs new file mode 100644 index 0000000000..8e653875c1 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -0,0 +1,84 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.workspace-comments-test + (:require + [app.main.data.comments :as dcmt] + [app.main.data.workspace.comments :as dwcm] + [app.main.data.workspace.edition :as dwe] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(t/deftest test-handle-interrupt-draft + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:comments-local {:draft {:id "draft-id"}}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dcmt/close-comment-thread (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-open + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:comments-local {:open {:id "thread-id"}}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dcmt/close-comment-thread (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-comments-mode + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:workspace-drawing {:tool :comments}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dwe/clear-edition-mode (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-noop + (t/async + done + (let [event (dwcm/handle-interrupt) + state {} + result (ptk/watch event state (rx/empty)) + emitted? (atom false)] + (->> result + (rx/subs! + (fn [_] + (reset! emitted? true)) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (t/is (false? @emitted?) "should not emit any events") + (done))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 0e92dd804a..f21b124277 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -12,6 +12,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] @@ -85,6 +86,7 @@ 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test From 23a9b4bdd9a80deeb5d6256d035378c818173be5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 18:53:49 +0200 Subject: [PATCH 12/16] :bug: Fix backend util shell tests --- backend/src/app/util/shell.clj | 4 +++- backend/test/backend_tests/shell_test.clj | 27 ++++++++++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 0d7349d632..61dd08e682 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -65,7 +65,6 @@ (assert (every? string? cmd) "the command should be a vector of strings") (let [executor (::wrk/executor system) - _ (assert (some? executor) "executor is required, check ::wrk/executor") full-cmd (cond->> cmd (seq prlimit) (into (prlimit-cmd prlimit))) @@ -74,6 +73,9 @@ _ (reduce-kv set-env env-map env) process (.start builder)] + (when-not executor + (throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor"))) + (if in (px/run! executor (fn [] diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c77c585568..c9d1932c44 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -8,12 +8,17 @@ (:require [app.common.exceptions :as ex] [app.util.shell :as shell] + [app.worker :as-alias wrk] [clojure.string :as str] - [clojure.test :as t])) + [clojure.test :as t] + [promesa.exec :as px])) + +(def ^:private system + {::wrk/executor (px/cached-executor)}) (t/deftest exec-normal-completes (t/testing "normal process completes within timeout" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -21,7 +26,7 @@ (t/deftest exec-captures-stderr (t/testing "stderr is captured separately" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo out; echo err >&2"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -30,14 +35,14 @@ (t/deftest exec-non-zero-exit (t/testing "non-zero exit code is captured" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "exit 42"] :timeout 10)] (t/is (= 42 (:exit result)))))) (t/deftest exec-with-env (t/testing "environment variables are passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo $MY_VAR"] :env {"MY_VAR" "test-value"} :timeout 10)] @@ -46,7 +51,7 @@ (t/deftest exec-with-input (t/testing "stdin input is passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["cat"] :in "hello from stdin" :timeout 10)] @@ -57,7 +62,7 @@ (t/testing "process that exceeds timeout is killed and raises exception" (let [start (System/currentTimeMillis)] (try - (shell/exec! {} + (shell/exec! system :cmd ["sleep" "60"] :timeout 1) (t/is false "should have thrown") @@ -72,14 +77,14 @@ (t/deftest exec-no-timeout-waits (t/testing "without timeout, process runs to completion" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["sleep" "0.1"] :timeout nil)] (t/is (= 0 (:exit result)))))) (t/deftest exec-prlimit-normal (t/testing "normal process completes within prlimit" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :prlimit {:mem 256 :cpu 10} :timeout 10)] @@ -88,7 +93,7 @@ (t/deftest exec-prlimit-cpu (t/testing "process exceeding CPU limit is killed" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "while true; do :; done"] :prlimit {:cpu 2} :timeout 10)] @@ -98,7 +103,7 @@ (t/testing "process exceeding memory limit is killed" ;; Use python3 to allocate more memory than the limit allows. ;; This test requires python3 to be available in the environment. - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["python3" "-c" "import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"] :prlimit {:mem 256} From ad4dae5f289390bb4b1457f5bfbf7db57a4324a4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 19:40:01 +0200 Subject: [PATCH 13/16] :books: Add testing principles to serena doc --- .serena/memories/backend/core.md | 1 + .serena/memories/common/core.md | 1 + .serena/memories/common/testing-principles.md | 47 +++++++++++++++++++ .serena/memories/frontend/core.md | 1 + 4 files changed, 50 insertions(+) create mode 100644 .serena/memories/common/testing-principles.md diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index d815f69070..f416b78cf8 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -106,3 +106,4 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. * **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. * **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. +* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index 474fb8d87e..23fbe20198 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -49,6 +49,7 @@ Components, variants, and debugging: Text and tests: - Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. - Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`. +- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. ## Areas without focused memories diff --git a/.serena/memories/common/testing-principles.md b/.serena/memories/common/testing-principles.md new file mode 100644 index 0000000000..860852393b --- /dev/null +++ b/.serena/memories/common/testing-principles.md @@ -0,0 +1,47 @@ +# Testing Principles (Cross-Cutting) + +Shared testing principles for all modules (backend, frontend, common, render-wasm, plugins). Module-specific commands and helpers are in each module's own testing memory. + +## Core Principles + +- **Test State, Not Interactions** — assert on outcomes, not method calls; survives refactoring +- **DAMP over DRY** — tests are specifications; duplication OK if each test is self-contained and readable +- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock; mock only at boundaries (network, filesystem, email) +- **Arrange-Act-Assert** — every test: setup / action / verify +- **One Assertion Per Concept** — each test verifies one behavior; split compound assertions +- **Descriptive Test Names** — names read like specifications + +## Anti-Patterns + +- Testing implementation details → breaks on refactor +- Flaky tests (timing, order-dependent) → erode trust +- Mocking everything → tests pass, production breaks +- No test isolation → pass individually, fail together +- Testing framework code → waste of time +- Snapshot abuse → nobody reviews, break on any change + +## Verification Checklist + +After completing any implementation: +- Every new behavior has a corresponding test +- All tests pass for touched modules +- Bug fixes include a reproduction test that failed before the fix +- Test names describe the behavior being verified +- No tests were skipped or disabled + +## Red Flags + +- Writing code without corresponding tests +- Tests that pass on the first run (may not be testing what you think) +- Bug fixes without reproduction tests +- Tests that test framework behavior instead of app behavior +- Skipping tests to make the suite pass + +## Rationalizations + +- "I'll write tests after" → you won't; they'll test implementation not behavior +- "Too simple to test" → simple gets complicated; test documents expected behavior +- "Tests slow me down" → they speed up every future change +- "I tested manually" → manual testing doesn't persist +- "Code is self-explanatory" → tests ARE the specification +- "Just a prototype" → prototypes become production; test debt accumulates diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 7edf80bbe1..b890de6770 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -52,6 +52,7 @@ Diagnostics and validation: - Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`. - Runtime crash recovery: `mem:frontend/handling-crashes`. - Tests and live verification: `mem:frontend/testing`. +- Cross-cutting testing principles and anti-patterns: `mem:common/testing-principles`. - Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`. ## Areas without focused memories From e73aa9e981dd2857459ea0b2495237cf36e064e1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Fri, 10 Jul 2026 10:41:49 +0200 Subject: [PATCH 14/16] :sparkles: Add PENPOT_INTERNAL_URI to exporter for separate internal and public URI handling (#10630) Add PENPOT_INTERNAL_URI environment variable to the exporter. This allows separating the URI used for internal communication (headless browser to frontend) from the public URI used for resource references in exported SVGs. Previously, PENPOT_PUBLIC_URI served both purposes, which caused exported SVGs to contain broken font URLs when the internal Docker address was used. Changes: - Add :internal-uri to exporter config schema with fallback to :public-uri - Add get-internal-uri helper function - Use internal-uri for browser navigation in SVG/PDF/bitmap renderers - Post-process SVG output to replace internal URI with public URI - Use internal-uri for backend API calls in resource handler - Log both URIs on startup - Update docker-compose.yaml to use both variables - Document the new variable in configuration.md Closes #10627 AI-assisted-by: mimo-v2.5-pro --- docker/images/docker-compose.yaml | 4 ++-- docs/technical-guide/configuration.md | 15 +++++++++++++++ exporter/src/app/config.cljs | 8 ++++++++ exporter/src/app/core.cljs | 1 + exporter/src/app/handlers/resources.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/pdf.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 18 ++++++++++++++++-- 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 94193a977c..59b326c76d 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -197,10 +197,10 @@ services: - penpot environment: - << : [*penpot-secret-key] + << : [*penpot-secret-key, *penpot-public-uri] # Don't touch it; this uses an internal docker network to # communicate with the frontend. - PENPOT_PUBLIC_URI: http://penpot-frontend:8080 + PENPOT_INTERNAL_URI: http://penpot-frontend:8080 ## Valkey (or previously Redis) is used for the websockets notifications. PENPOT_REDIS_URI: redis://penpot-valkey/0 diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index eec62f63e4..49d5a23e8d 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -651,6 +651,21 @@ PENPOT_EXPORTER_URI: http://your-penpot-exporter:6061 These variables are used for generate correct nginx.conf file on container startup. +### Exporter + +The exporter uses this variable: + +```bash +# Exporter +PENPOT_INTERNAL_URI: http://penpot-frontend:8080 +``` + +- `PENPOT_INTERNAL_URI`: The URI used by the exporter's headless browser to + communicate with the frontend (internal Docker network). Defaults to + `PENPOT_PUBLIC_URI` if not set. The default value + `http://penpot-frontend:8080` used in the docker-compose is a good default and + it is recommended to keep it unchanged. + ## Other flags There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index ff32391f82..8010032680 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -22,6 +22,7 @@ (def ^:private defaults {:public-uri "http://localhost:3449" + :internal-uri nil :tenant "default" :host "localhost" :http-server-port 6061 @@ -33,6 +34,7 @@ [:map {:title "config"} [:secret-key :string] [:public-uri {:optional true} ::sm/uri] + [:internal-uri {:optional true} ::sm/uri] [:exporter-shared-key {:optional true} :string] [:host {:optional true} :string] [:tenant {:optional true} :string] @@ -100,6 +102,12 @@ ([key default] (c/get config key default))) +(defn get-internal-uri + "Returns internal-uri if set, otherwise falls back to public-uri." + [] + (or (c/get config :internal-uri) + (c/get config :public-uri))) + (def management-key (let [key (or (c/get config :exporter-shared-key) (let [secret-key (c/get config :secret-key) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index 91d4ee702e..5ce68ebf98 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -21,6 +21,7 @@ [& _] (l/info :msg "initializing" :public-uri (str (cf/get :public-uri)) + :internal-uri (str (cf/get-internal-uri)) :version (:full cf/version)) (p/do! (bwr/init) diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index 9b2f37d6cc..4c4bb7225c 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -79,7 +79,7 @@ :method "POST" :body fdata :dispatcher agent} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "api/management/methods/upload-tempfile") (str))] diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index 3579a4839e..c2720eb025 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -62,7 +62,7 @@ :skip-children skip-children :wasm (when is-wasm "true") :scale scale} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") (assoc :query (u/map->query-string params)))] diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index b953730be4..ba4118c1e8 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -77,7 +77,7 @@ (on-object (assoc object :path path)) (p/recur (rest objects))))))] - (let [base-uri (-> (cf/get :public-uri) + (let [base-uri (-> (cf/get-internal-uri) (u/ensure-path-slash))] (bw/exec! (prepare-options base-uri) (partial render base-uri))))) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 7a47e46fe0..c9fee2f764 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -108,6 +108,18 @@ {:width width :height height})) +(defn- replace-internal-uris + "Replaces internal-uri references with public-uri in SVG output. + This ensures that font URLs and other resource references in the + exported SVG use the public-facing URI accessible to end users." + [svg-content] + (let [internal-uri (str (cf/get-internal-uri)) + public-uri (str (cf/get :public-uri))] + (if (and (not= internal-uri public-uri) + (str/includes? svg-content internal-uri)) + (str/replace svg-content internal-uri public-uri) + svg-content))) + (defn render [{:keys [page-id file-id share-id objects token scale type]} on-object] (letfn [(convert-to-ppm [pngpath] @@ -320,7 +332,9 @@ result (if (contains? cf/flags :exporter-svgo) (svgo/optimize result svgo/defaultOptions) - result)] + result) + + result (replace-internal-uris result)] ;; (println "------- ORIGIN:") ;; (cljs.pprint/pprint (xml->clj xmldata)) @@ -349,7 +363,7 @@ :render-embed true :object-id (mapv :id objects) :route "objects"} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") (assoc :query (u/map->query-string params)))] From 3708cf31d40581fb14c9b928a21b8b8f8c7be0ae Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Fri, 10 Jul 2026 11:05:09 +0200 Subject: [PATCH 15/16] :bug: Fix stroke cap selects (#10631) --- .../src/app/main/ui/components/select.scss | 2 +- .../sidebar/options/rows/stroke_row.cljs | 50 +++++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index 6baab75ac1..438e0873fa 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -150,7 +150,7 @@ .separator { margin: 0; block-size: $sz-12; - border-block-start: $b-1 solid var(--color-background-primary); + border-block-start: $b-1 solid var(--color-background-quaternary); } &[data-direction="up"] { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index b39d62c1cb..8e21f18232 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -171,12 +171,20 @@ on-caps-start-change (mf/use-fn (mf/deps index on-stroke-cap-start-change) - #(on-stroke-cap-start-change index (keyword %))) + (fn [cap] + (let [cap (if (= cap "none") + nil + (keyword cap))] + (on-stroke-cap-start-change index cap)))) on-caps-end-change (mf/use-fn (mf/deps index on-stroke-cap-end-change) - #(on-stroke-cap-end-change index (keyword %))) + (fn [cap] + (let [cap (if (= cap "none") + nil + (keyword cap))] + (on-stroke-cap-end-change index cap)))) on-detach-token-color (mf/use-fn @@ -191,16 +199,16 @@ (on-detach-token token #{:stroke-width}))) stroke-caps-options - [{:value nil :label (tr "workspace.options.stroke-cap.none")} - :separator - {:value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon :stroke-arrow} - {:value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon :stroke-triangle} - {:value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon :stroke-rectangle} - {:value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon :stroke-circle} - {:value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon :stroke-diamond} - :separator - {:value :round :label (tr "workspace.options.stroke-cap.round") :icon :stroke-rounded} - {:value :square :label (tr "workspace.options.stroke-cap.square") :icon :stroke-squared}] + [{:id "none" :value "none" :label (tr "workspace.options.stroke-cap.none")} + {:label "" :type :separator :id "separator"} + {:id "line-arrow" :value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon i/stroke-arrow} + {:id "triangle-arrow" :value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon i/stroke-triangle} + {:id "square-marker" :value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon i/stroke-rectangle} + {:id "circle-marker" :value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon i/stroke-circle} + {:id "diamond-marker" :value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon i/stroke-diamond} + {:label "" :type :separator :id "separator"} + {:id "round" :value :round :label (tr "workspace.options.stroke-cap.round") :icon i/stroke-rounded} + {:id "square" :value :square :label (tr "workspace.options.stroke-cap.square") :icon i/stroke-squared}] on-cap-switch (mf/use-fn @@ -340,16 +348,18 @@ ;; Stroke Caps (when show-caps [:div {:class (stl/css :stroke-caps-options)} - [:& select {:default-value (:stroke-cap-start stroke) - :options stroke-caps-options - :disabled hidden? - :on-change on-caps-start-change}] + [:> select* {:default-selected (or (d/name (:stroke-cap-start stroke)) "none") + :options stroke-caps-options + :data-testid "stroke.cap-start" + :disabled hidden? + :on-change on-caps-start-change}] [:> icon-button* {:variant "secondary" :aria-label (tr "labels.switch") :disabled hidden? :on-click on-cap-switch :icon i/switch}] - [:& select {:default-value (:stroke-cap-end stroke) - :options stroke-caps-options - :disabled hidden? - :on-change on-caps-end-change}]])])) + [:> select* {:default-selected (or (d/name (:stroke-cap-end stroke)) "none") + :options stroke-caps-options + :data-testid "stroke.cap-end" + :disabled hidden? + :on-change on-caps-end-change}]])])) From 1f4b85209ebe6e1831fa08f2df581e9049689e61 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Fri, 10 Jul 2026 11:22:44 +0200 Subject: [PATCH 16/16] :books: Simplify the ia asistance note on creating-prs serena workflow --- .serena/memories/workflow/creating-prs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 204fd49c76..c0f763f9e3 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -38,7 +38,7 @@ Include concise sections covering: PR descriptions follow this structure: ```markdown -**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative. +**Note:** This PR was created with AI assistance. ## What