diff --git a/.agents/README.md b/.agents/README.md
new file mode 100644
index 0000000000..165a4a7745
--- /dev/null
+++ b/.agents/README.md
@@ -0,0 +1,91 @@
+# Agent skills
+
+This folder is the single home for the skills our coding agents use.
+Each skill is a folder with a `SKILL.md` inside — a short instruction
+manual that an agent loads only when it needs it.
+
+One copy serves every tool:
+
+- **opencode** reads this folder directly.
+- **Claude Code** reads it through the `.claude/skills` symlink.
+- **Codex** reads it directly.
+
+To change how the agents behave, edit the `SKILL.md` here. There is no
+second copy to keep in sync.
+
+## How the skills are organized
+
+**Flows** are the six skills you invoke by name. Each one covers one step
+in the life of a change: plan it, review the plan, implement it, review
+the code, open the pull request.
+
+**References** hold the quality standards. A flow's reviewer loads them;
+you rarely touch them directly.
+
+**Procedures** define how one concrete step is done — a plan document, an
+issue, a commit. Flows call them, but they also work on their own.
+
+**Utilities** are small helpers for everyday work: search, file lookup,
+JSON, REPL access, and so on.
+
+## Flows
+
+| Skill | What it does | When you would say |
+|---|---|---|
+| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" |
+| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" |
+| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. On request ("direct"): no issue and no branch, commits on the current branch. | "implement the plan" · "step by step, one commit per task" · "direct, no branch" |
+| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" |
+| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
+| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
+
+## References
+
+| Skill | What it holds |
+|---|---|
+| [`plan-review-criteria`](skills/plan-review-criteria/SKILL.md) | The plan review rubric: six axes, severity levels, approval standard, output format. The `review-plan` reviewer loads it. |
+| [`code-review-criteria`](skills/code-review-criteria/SKILL.md) | The code review rubric: five axes, core principles (DRY, KISS, YAGNI), severity format, verdict. The `review-code` reviewer loads it. |
+
+## Procedures
+
+| Skill | What it does |
+|---|---|
+| [`planner`](skills/planner/SKILL.md) | The spec of a good plan: context, architecture decisions, tasks with acceptance criteria, checkpoints. Used by `make-a-plan`. |
+| [`create-issue`](skills/create-issue/SKILL.md) | Creates a GitHub issue that follows Penpot conventions. Used by `implement-plan`; also works on its own. |
+| [`create-commit`](skills/create-commit/SKILL.md) | Makes a commit the Penpot way: emoji subject, clear body, `AI-assisted-by` trailer. Used by `implement-plan`; also works alone when you say "commit this". |
+
+## Utilities
+
+| Skill | What it does |
+|---|---|
+| [`bat-cat`](skills/bat-cat/SKILL.md) | Read files in the terminal with syntax highlighting and line numbers. |
+| [`fd-find`](skills/fd-find/SKILL.md) | Find files by name or pattern, respecting `.gitignore`. |
+| [`ripgrep`](skills/ripgrep/SKILL.md) | Fast content search with regular expressions. |
+| [`jq-json-processor`](skills/jq-json-processor/SKILL.md) | Slice, filter, and reshape JSON output. |
+| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). |
+| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. |
+| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. |
+| [`local-ci`](skills/local-ci/SKILL.md) | Run CI-style lint, test, and format checks for the modules you touched with `scripts/ci`, and read the logs when they fail. |
+| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. |
+| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
+| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
+| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. |
+
+## A typical round
+
+1. `/make-a-plan` — you get a plan and a saved file in `.agents/plans/`.
+2. `/review-plan` — a second opinion; approve or request changes.
+3. `/implement-plan` — the code gets written and committed. Starting from a base branch, it also opens the GitHub issue and the `issue-NNNN` branch; the plans that follow continue on that same branch.
+4. `/review-code` — a reviewer checks the commit.
+5. `/create-pr` — the branch goes up as a pull request.
+
+Every step also works on its own, and you can always say what you want
+in plain words — the agents pick the right skill from what you say.
+
+## Adding or changing a skill
+
+Create a folder here with a `SKILL.md` inside. The file needs `name` and
+`description` in its frontmatter, and a clear "When to use" section so
+agents know when to reach for it. Keep one job per skill, and keep the
+two families apart: flows are named with a verb first; reference skills
+end in `-criteria`.
diff --git a/.opencode/skills/bat-cat/SKILL.md b/.agents/skills/bat-cat/SKILL.md
similarity index 96%
rename from .opencode/skills/bat-cat/SKILL.md
rename to .agents/skills/bat-cat/SKILL.md
index 61ca8def8f..2d67404725 100644
--- a/.opencode/skills/bat-cat/SKILL.md
+++ b/.agents/skills/bat-cat/SKILL.md
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"i
`cat` with syntax highlighting, line numbers, and Git integration.
+## When to use
+
+- Reading or displaying a file in the terminal — prefer it over plain
+ `cat`: syntax highlighting, line numbers, git-side indicators.
+
## Quick Start
### Basic usage
diff --git a/.opencode/skills/code-review/SKILL.md b/.agents/skills/code-review-criteria/SKILL.md
similarity index 95%
rename from .opencode/skills/code-review/SKILL.md
rename to .agents/skills/code-review-criteria/SKILL.md
index 7fa581efa0..59abf1c21e 100644
--- a/.opencode/skills/code-review/SKILL.md
+++ b/.agents/skills/code-review-criteria/SKILL.md
@@ -1,9 +1,9 @@
---
-name: code-review
-description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
+name: code-review-criteria
+description: Code review criteria — the five review axes, core principles, severity format, and verdict for reviewing code changes. Loaded by the reviewer subagent of the review-code flow. Not a user-facing flow — to review code, use the review-code flow.
---
-# Code Review and Quality
+# Code Review Criteria and Quality
## Overview
@@ -13,11 +13,10 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
## When to Use
-- Before merging any PR or change
-- After completing a feature implementation
-- When another agent or model produced code you need to evaluate
-- When refactoring existing code
-- After any bug fix (review both the fix and the regression test)
+- The reviewer subagent of the `review-code` flow loads this skill to perform
+ the review of a code change.
+- To review code, always go through the `review-code` flow — never load this
+ skill directly for that. This is the criteria reference, not the flow.
## Core Principles
diff --git a/.opencode/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md
similarity index 100%
rename from .opencode/skills/create-commit/SKILL.md
rename to .agents/skills/create-commit/SKILL.md
diff --git a/.opencode/skills/create-issue/SKILL.md b/.agents/skills/create-issue/SKILL.md
similarity index 100%
rename from .opencode/skills/create-issue/SKILL.md
rename to .agents/skills/create-issue/SKILL.md
diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md
new file mode 100644
index 0000000000..703609c9a1
--- /dev/null
+++ b/.agents/skills/create-pr/SKILL.md
@@ -0,0 +1,105 @@
+---
+name: create-pr
+description: PR flow — open a new PR for the current task branch (validates base branch, commits, issue and push state) or update an existing PR's title or description to match Penpot conventions. Use it when the user asks to open or create a PR, in any phrasing.
+---
+
+# Create PR
+
+Two modes. **Open mode** takes the current task branch to a new, validated
+PR. **Update mode** rewrites an existing PR's title or description. Gather
+information, validate, and act in one pass. If validation fails, STOP with a
+single coherent message that lists every problem and states exactly what
+information is missing — never fix or work around problems silently.
+
+Both modes require an authenticated `gh` CLI (`gh auth status`) and never
+push — the user pushes from their own shell.
+
+## When to use
+
+- The user asks to open or create a NEW PR for the current task branch, in
+ any phrasing ("open a PR", "create the pull request", "put this up for
+ review") — or runs `/create-pr`. → **Open mode**.
+- The user asks to fix or update an EXISTING PR's title or description to
+ match conventions. → **Update mode**.
+
+If the running agent cannot write (for example, the plan agent), say so and
+stop — this skill needs the build agent.
+
+## Open mode
+
+### 1. Gather context (read-only)
+
+- Current branch: `git rev-parse --abbrev-ref HEAD`.
+- Target base branch: run `./scripts/detect-target-branch` from the repo root.
+ It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1).
+- Commits: `git log --oneline ..HEAD`.
+- Push state (local): `git rev-parse --verify origin/` and compare
+ with HEAD. It reads the local remote-tracking ref — no network, no SSH. It
+ reflects the last push or fetch this clone knows about.
+- Issue: from the session context, or from the branch name — `issue-NNNN`
+ maps to issue NNNN; recover its title and body with `gh issue view NNNN`.
+
+### 2. Validate — stop with one message if anything fails
+
+Run all checks before reporting, then report every failure together:
+
+1. **Base branch not usable.** If the script fails (exit 1), or its output —
+ after stripping an optional `remotes/origin/` prefix — is not one of the
+ canonical branches (`develop`, `staging`, `main`), stop and ask the user
+ to re-run with more context — for example, passing the base branch
+ explicitly in their invocation. An explicit base given by the user
+ overrides the script's output.
+2. **On a base branch.** There is no task branch to merge — say so and stop.
+3. **No commits.** The branch has no commits ahead of the base — say so and
+ stop.
+4. **No clear issue.** There is no issue in the session context, and the
+ branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing)
+ — say so and stop. Exception: the user's invocation says `no issue` /
+ `without issue` — then continue without an issue reference.
+5. **Branch not pushed.** The remote-tracking ref `origin/` is
+ missing, or `git rev-parse origin/` differs from HEAD — the
+ branch was never pushed, or has commits the remote does not have. Never
+ push yourself; ask the user to push and to run `/create-pr` again
+ afterwards, then stop.
+
+### 3. Already-open PR
+
+Check whether a PR already exists for this branch (`gh pr list --head
+`). If one exists, report its URL and stop — do not create a second
+one. Title or description fixes belong to Update mode.
+
+### 4. Write and create the PR
+
+Write the title and body following `mem:workflow/creating-prs` (title format,
+description structure, writing principles) and `mem:workflow/creating-commits`
+(commit type emojis). Derive the title and body from the commits and, when
+there is one, from the issue body. Reference the issue with `Closes #NNNN`.
+
+```bash
+gh pr create --repo penpot/penpot --title "" --body-file /tmp/pr-body.md
+```
+
+### 5. Report
+
+Report the PR URL and stop.
+
+## Update mode
+
+1. Identify the PR: the number given by the user, or `gh pr list --head
+ `.
+2. Write the new title and/or body following `mem:workflow/creating-prs`.
+3. Apply and verify:
+
+```bash
+gh pr edit --repo penpot/penpot --title "" --body-file /tmp/pr-body.md
+gh pr view --repo penpot/penpot --json title,body
+```
+
+4. Report and stop.
+
+## User context
+
+Extra context in the user's invocation (the message that triggered this skill)
+plays the role command arguments play elsewhere: overrides such as `no issue` /
+`without issue`, an explicit base branch (`from origin/staging`), a PR number
+for Update mode, and so on.
diff --git a/.opencode/skills/fd-find/SKILL.md b/.agents/skills/fd-find/SKILL.md
similarity index 95%
rename from .opencode/skills/fd-find/SKILL.md
rename to .agents/skills/fd-find/SKILL.md
index e218ac9bfd..7d5e8fae4f 100644
--- a/.opencode/skills/fd-find/SKILL.md
+++ b/.agents/skills/fd-find/SKILL.md
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id
User-friendly alternative to `find` with smart defaults.
+## When to use
+
+- Locating files or directories by name or pattern — prefer it over
+ plain `find`: simpler syntax, smart defaults, respects `.gitignore`.
+
## Quick Start
### Basic search
diff --git a/.agents/skills/implement-plan/SKILL.md b/.agents/skills/implement-plan/SKILL.md
new file mode 100644
index 0000000000..807b60064c
--- /dev/null
+++ b/.agents/skills/implement-plan/SKILL.md
@@ -0,0 +1,144 @@
+---
+name: implement-plan
+description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each; on request ("direct"), no issue and no branch — the commit lands on the current branch. Use it when the user asks to implement or execute a plan, in any phrasing.
+---
+
+# Implement Plan
+
+This flow is run once a plan is ready (for example, from plan mode). Execute
+the plan already prepared in the current session context. It never pushes —
+the user pushes.
+
+By default it ends with exactly one commit. When the user asks for it
+("step by step"), it commits once per task instead and waits for the
+user's confirmation after each one (see *Execution modes*).
+
+## When to use
+
+- The user asks to implement or execute a plan, in any phrasing:
+ "implement the plan", "execute it", "go build it" — or runs
+ `/implement-plan`.
+- A ready, reviewed plan is in the session context or a plan file path
+ was given (typically after `/make-a-plan` or `/review-plan`).
+
+Do not use it to produce plans — that is the `make-a-plan` flow.
+
+## 1. Read the plan first
+
+Identify the plan to execute — from the file path the user gave, the
+arguments, or the session context. Read it completely. Read the required
+memories before writing any code: `mem:critical-info` and the core memory
+of every module the plan touches, plus the deeper memories they reference
+(AGENTS.md governs this).
+
+## 2. Detect the flow (no questions)
+
+Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the
+mode, and announce it in one line before presenting anything. Detection is
+read-only: nothing is created until the user confirms (step 3).
+
+- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**:
+ a new GitHub issue and a branch `issue-NNNN` will be created after the
+ user's confirmation.
+- **On any other branch** (a feature branch, typically `issue-NNNN`) →
+ **continue mode**: the implementation continues on the current branch.
+ No issue or branch is created. The branch name provides the issue
+ reference when it follows the `issue-NNNN` pattern.
+
+Arguments override detection: `standalone`, `continue`, `direct`
+(`no branch` / `direct commit`), `no issue` / `without issue`, or an
+explicit base such as `from origin/develop`.
+
+**Direct mode** (`direct`, `no branch`, `direct commit`): no issue and
+no branch — the implementation and the commit land on the current branch
+as it is, even when it is a base branch. Best for small or tooling-only
+changes the user wants committed in place.
+
+**Standalone while already on a feature branch:** stop and explain that this
+would stack branches. Ask the user to re-run with an explicit base, for
+example `from origin/develop` — then branch from that base instead of HEAD.
+
+## 3. Present the checklist and wait
+
+Before touching the repository, show the user the full picture:
+
+- **The flow**: whether the GitHub issue and the branch will be created
+ (standalone mode — give the planned branch name, `issue-NNNN` or
+ `plan-`), whether you continue on the current branch
+ (continue mode — name it), or whether everything lands on the current
+ branch as it is (direct mode — name it, and say so when it is a base
+ branch).
+- **The execution style**: batch or step-by-step (see *Execution modes*).
+- A checklist (todolist) of the plan's tasks, in order.
+
+Then WAIT for the user's explicit confirmation. Do not start until you
+have it. If the plan has no discrete tasks, ask the user how to split
+it, or propose running it as a single change.
+
+## 4. Execute the plan
+
+**Standalone setup, after the confirmation:** create the issue with the
+**`create-issue`** skill, following the *Creating Issues from Draft Body*
+flow in `mem:workflow/creating-issues`. Derive the issue title and body
+from the plan, capture the new issue's number — call it **NNNN** — and
+create the branch from the current HEAD:
+
+```
+git checkout -b issue-NNNN
+```
+
+If the arguments say `no issue` / `without issue`, skip the issue and
+create a branch named `plan-` instead, where `` is the plan
+title, lowercase and hyphen-separated.
+
+If the arguments say `direct` / `no branch` / `direct commit`, skip the
+issue and the branch: implement and commit on the current branch as it
+is. If it is a base branch, the checklist presentation already said so —
+no further confirmation is needed.
+
+### Batch mode (default)
+
+Implement every task in one go. Work methodically, keeping changes
+focused on what the issue requires. Respect the plan's proposed
+parallelization when it applies.
+
+When the implementation is complete, load the **`create-commit`** skill
+and follow its workflow to commit the changes. Provide a brief summary
+of what was implemented and why, the issue reference (`issue-NNNN`) when
+there is one, and the model name you are running as so the
+`AI-assisted-by` trailer is set correctly.
+
+### Step-by-step mode (on request)
+
+When the user asks for it — "step by step", "task by task", "one commit
+per task" — loop one task at a time:
+
+- Execute exactly ONE task.
+- Commit it now: load the **`create-commit`** skill and follow it —
+ one commit per task, never two tasks in one commit. Same inputs as
+ always: what and why, the issue reference, your model name.
+- Show the user the result (what changed, files touched, how it was
+ verified).
+- WAIT for the user's confirmation before starting the next task.
+
+Never batch in this mode: no two tasks in one commit, and no new task
+before the user confirms. If a task turns out much bigger than planned,
+stop and ask the user before splitting it.
+
+## When you are done
+
+End by suggesting the next steps (suggestions, not a required pipeline — any
+instruction from me overrides them):
+
+- `/review-code` — to review the changes just committed; it routes to
+ `/make-a-plan` by itself if the findings need one.
+- `/create-pr` — when the task is done and the branch is ready to merge.
+
+## User context
+
+Extra context in the user's invocation (the message that triggered this
+skill) plays the role command arguments play elsewhere: `standalone`,
+`continue`, `direct` (`no branch` / `direct commit`), `no issue` /
+`without issue`, an explicit base such as `from origin/develop`, or
+`step by step` / `one commit per task` for the step-by-step execution
+mode. Modes combine freely, for example "standalone step by step".
diff --git a/.opencode/skills/jq-json-processor/SKILL.md b/.agents/skills/jq-json-processor/SKILL.md
similarity index 94%
rename from .opencode/skills/jq-json-processor/SKILL.md
rename to .agents/skills/jq-json-processor/SKILL.md
index 83fe48d7bf..11687a09ff 100644
--- a/.opencode/skills/jq-json-processor/SKILL.md
+++ b/.agents/skills/jq-json-processor/SKILL.md
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id
Process, filter, and transform JSON data with jq.
+## When to use
+
+- Parsing, filtering, or transforming JSON from commands, files, or API
+ responses — slicing, reshaping, or validating JSON output.
+
## Quick Examples
### Basic filtering
diff --git a/.agents/skills/local-ci/SKILL.md b/.agents/skills/local-ci/SKILL.md
new file mode 100644
index 0000000000..5cda6acf89
--- /dev/null
+++ b/.agents/skills/local-ci/SKILL.md
@@ -0,0 +1,95 @@
+---
+name: local-ci
+description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors.
+---
+
+# Local CI
+
+Run the same checks CI runs, locally, for the modules you touched, with
+`scripts/ci`. Each task writes a log file; the final summary says what
+passed and what failed.
+
+Full details: `mem:scripts/ci` (file: `.serena/memories/scripts/ci.md`)
+
+## When to use
+
+- After implementing or fixing code — verify every module you touched
+ before declaring the work done.
+- When the user asks to run CI, lint, tests, or format checks locally.
+- When you changed `common/` — validate its consumers too.
+
+**Skip:** while exploring, planning, or reading code.
+
+## Command reference
+
+Run from the repo root:
+
+```bash
+./scripts/ci [OPTIONS] [MODULES...]
+```
+
+Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp`
+`plugins` `library`, or `--all` for every module.
+
+With no task flags it runs three tasks per module, in order: **lint**,
+**test**, **fmt** (format check; `--fix` formats files instead).
+
+| Flag | Effect |
+|------|--------|
+| `--all` | Run every module |
+| `--exclude MOD` | Skip one module (repeatable) |
+| `--lint` / `--no-lint` | Run only lint / drop lint |
+| `--test` / `--no-test` | Run only tests / drop tests |
+| `--fmt` / `--no-fmt` | Run only format check / drop it |
+| `--fix` | Format files instead of checking (other tasks unaffected) |
+| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files |
+| `--fail-fast` | Stop at the first failure |
+| `--quiet` | Suppress failure output |
+| `--dry-run` | Show what would run, execute nothing |
+| `--clean` | Delete the `.ci-logs/` directory |
+
+## Reading failures
+
+Every task writes its full output to `.ci-logs/-.log`. On
+failure the script prints only the last 30 lines. To diagnose a failure,
+**read the log file** — never re-run the command piped through filters
+(repo rule: redirect to a file first, then read it). The exit code is 1
+when any task failed; the summary lists each failed `module:task` and its
+log path.
+
+## Typical workflows
+
+```bash
+# Verify a module you changed: lint + tests + format check
+./scripts/ci frontend
+
+# Fast pass while iterating: lint only
+./scripts/ci --lint frontend
+
+# Lint + format check, skip the long test suite
+./scripts/ci --no-test frontend
+
+# Format the module without running the test suite
+./scripts/ci --fix --no-test frontend
+
+# Broke delimiters in Clojure/CLJS files: repair first, then lint
+./scripts/ci --paren-repair frontend
+./scripts/ci --lint frontend
+
+# Changed common/ — validate its consumers too
+./scripts/ci frontend backend exporter
+
+# Preview what would run, without running it
+./scripts/ci --dry-run --all
+```
+
+## Gotchas
+
+- Run from the repo root.
+- Test tasks are long-running (backend runs `clojure -M:dev:test`); give
+ the bash call a generous timeout (10–20 minutes) instead of letting it
+ time out mid-run.
+- `mcp` has no lint task — it shows as skipped, not failed.
+- `--paren-repair` only fixes delimiters; run lint afterwards to catch
+ what remains. See `mem:scripts/paren-repair`.
+- What to run and how to read test results: `mem:testing`.
diff --git a/.agents/skills/make-a-plan/SKILL.md b/.agents/skills/make-a-plan/SKILL.md
new file mode 100644
index 0000000000..cd9129f85b
--- /dev/null
+++ b/.agents/skills/make-a-plan/SKILL.md
@@ -0,0 +1,100 @@
+---
+name: make-a-plan
+description: Planning flow — research the subject of this session, produce an implementation plan with the planner skill, resolve open questions with the user in plain language, and save the final plan to .agents/plans/. Use it when the user asks to plan, design, or break down a task, in any phrasing.
+---
+
+# Make a Plan
+
+Act as a senior software engineer: research the subject of this session in depth and
+produce a well-grounded, actionable implementation plan.
+
+If the running agent cannot write (for example, the plan agent), say so and
+stop — this skill needs the build agent to save the plan.
+
+## When to use
+
+- The user asks to plan, design, or break down a task, in any phrasing:
+ "make a plan", "how would we build X", "design an approach for Y" —
+ or runs `/make-a-plan`.
+- The user asks to rework or extend an existing plan (for example, after
+ review findings) — revise the saved plan file in place.
+
+Do not use it to execute a plan — that is the `implement-plan` flow.
+
+## Instructions
+
+1. **Produce the plan** with the `planner` skill. By default, research the
+ subject of this session and draft the plan yourself. If I ask for it (for
+ example, `delegated` in the user context), delegate to the `general` subagent
+ instead — the delegate must also follow the `planner` skill and receive all
+ the relevant session context (a review, user feedback, and so on).
+2. Before asking me to decide anything, explain the plan and every open question in
+ plain language. Assume I know only the high-level project goal, not the codebase,
+ architecture, implementation terms, or the problem this task solves.
+3. Once all decisions are answered and the plan is final, save it verbatim to the
+ announced path under `.agents/plans/` (create the directory if it does not
+ exist). This step is the flow's explicit authorization to write the plan
+ file — the only write allowed here. If I later ask for changes, update the
+ saved file directly.
+4. Present me with a clear, self-contained summary of the plan's most relevant points
+ only after all required decisions have been answered. Write it for someone who knows
+ only the project's high-level goal and may not know the plan's low-level context.
+ Explain necessary technical language in plain terms, include the problem being
+ solved and the proposed outcome, and do not assume that listing technical task names
+ is enough.
+
+### Hard rule — read-only while planning
+
+While this flow runs, act read-only: research with read-only tools only.
+Never edit source files, never run builds, tests, linters, or any command that
+modifies state, and never commit. The single allowed write is the plan file in
+step 3. This rule expires when I approve the plan or move on to another task;
+then you act as a normal build agent again.
+
+When the plan contains open questions, do not show them as bare technical questions or
+assume that I understand the technical language or technical words used in the plan.
+For each question, first explain:
+
+- What part of the user problem the decision affects.
+- The relevant concept from the beginning, with a small concrete example.
+- What each available option would make the system do.
+- The practical benefits, costs, risks, and user-visible consequences of each option.
+- Which option the planner recommends and why.
+
+Only after that explanation, use the `question` tool to ask the decision with clear,
+non-technical option labels. Put the recommended option first and mark it as
+`(Recommended)`. Group related questions when their context is shared, but do not ask a
+question whose meaning has not already been explained.
+
+If I say that I do not understand a question or its choices, do not treat my previous
+answer as valid. Explain the concepts again from the high-level project goal, use a more
+concrete example, explain the implications, and ask the question again with the
+`question` tool. Repeat this until I can make an informed choice. If one answer creates
+new design consequences or additional decisions, explain those consequences before
+asking any new question.
+
+Distinguish clearly between requirements already fixed by the roadmap or existing
+architecture and choices that actually require my input. Do not ask me to choose an
+implementation detail when the plan can resolve it safely without changing the public
+behavior. If there are no decisions that require my input, say so and present the
+summary.
+
+IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it
+after all possible questions have been answered.** The final summary must explain the
+problem being solved, the proposed behavior, the main user-visible workflow, important
+constraints and risks, what is deliberately out of scope, and the path where the plan
+is saved. Never assume that a short list of task names is enough context. End
+the final response by suggesting the next steps, in this order:
+
+1. `/review-plan` — to get a second opinion on the plan before executing it.
+2. `/implement-plan` — to execute the plan from the current session context.
+
+These are suggestions, not a required pipeline — any instruction from me
+overrides them (for example, asking you to implement the plan directly).
+
+## User context
+
+Extra context in the user's invocation (the message that triggered this skill)
+plays the role command arguments play elsewhere: for example, `delegated` to
+hand the research and drafting to the `general` subagent, or corrections and
+feedback about a previous plan.
diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.agents/skills/nrepl-eval/SKILL.md
similarity index 86%
rename from .opencode/skills/nrepl-eval/SKILL.md
rename to .agents/skills/nrepl-eval/SKILL.md
index c84dc803c1..0f7a025cbe 100644
--- a/.opencode/skills/nrepl-eval/SKILL.md
+++ b/.agents/skills/nrepl-eval/SKILL.md
@@ -10,6 +10,12 @@ Evaluate Clojure (or ClojureScript) code via a running nREPL server using
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
+## When to use
+
+- Evaluating Clojure or ClojureScript code against the running nREPL
+ sessions (backend 6064, frontend 3447) — live inspection, patching, or
+ debugging.
+
## Quick Reference
```bash
diff --git a/.opencode/skills/plan-review/SKILL.md b/.agents/skills/plan-review-criteria/SKILL.md
similarity index 94%
rename from .opencode/skills/plan-review/SKILL.md
rename to .agents/skills/plan-review-criteria/SKILL.md
index 4386d701cc..649bd1118f 100644
--- a/.opencode/skills/plan-review/SKILL.md
+++ b/.agents/skills/plan-review-criteria/SKILL.md
@@ -1,9 +1,9 @@
---
-name: plan-review
-description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
+name: plan-review-criteria
+description: Plan review criteria — the six review axes, severity rubric, approval standard, and output format for reviewing implementation plans. Loaded by the reviewer subagent of the review-plan flow. Not a user-facing flow — to review a plan, use the review-plan flow.
---
-# Plan Review
+# Plan Review Criteria
## Overview
@@ -13,10 +13,10 @@ Multi-dimensional plan review with quality gates. Every plan gets reviewed befor
## When to Use
-- After the planner skill produces a plan
-- Before starting implementation on any non-trivial task
-- When reviewing a plan written by another agent or a human
-- When a plan feels too large, vague, or risky to start
+- The reviewer subagent of the `review-plan` flow loads this skill to perform
+ the review of a plan.
+- To review a plan, always go through the `review-plan` flow — never load this
+ skill directly for that. This is the criteria reference, not the flow.
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
@@ -87,7 +87,7 @@ Can an implementer actually execute this?
### 6. Proposed Code Quality *(when the plan includes implementation details)*
-If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
+If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-criteria`:
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
- **Readability:** Are proposed names descriptive and consistent with project conventions?
@@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked:
If the plan includes code snippets, types, or API designs:
```
-- Load code-review skill for criteria
+- Load code-review-criteria skill for criteria
- Check proposed signatures for edge cases
- Verify naming follows project conventions
- Confirm abstractions follow existing patterns
@@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs:
## See Also
- For producing plans, use the `planner` skill
-- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
+- For reviewing implemented code, use `code-review-criteria` — also the criteria source for axis 6
- For security-specific concerns, see `security-and-hardening`
- For testing strategy guidance, see `testing`
diff --git a/.opencode/skills/planner/SKILL.md b/.agents/skills/planner/SKILL.md
similarity index 93%
rename from .opencode/skills/planner/SKILL.md
rename to .agents/skills/planner/SKILL.md
index 3598a0dc16..0bc58d7b4a 100644
--- a/.opencode/skills/planner/SKILL.md
+++ b/.agents/skills/planner/SKILL.md
@@ -1,6 +1,6 @@
---
name: planner
-description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-.md.
+description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
---
# Planner
@@ -215,9 +215,9 @@ Add explicit checkpoints with the relevant module commands:
## Constraints
-- You are **analysis-only** — never create, edit, or delete source code.
-- The only file write you may attempt is the plan itself, saved to
- `.opencode/plans/`.
+- You are **analysis-only** — never create, edit, or delete source code. The
+ only file you may write is the plan itself, and only when the command or
+ user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
@@ -228,22 +228,23 @@ Add explicit checkpoints with the relevant module commands:
## Output Format
The plan is always delivered in the response so the user sees it regardless
-of which agent is running the skill.
+of which agent is running the skill. By default you never write the plan file;
+announce the path instead. Write the file only when the command or user
+explicitly instructs you to save it — and then only that file.
-Additionally, save the plan to:
+Announce the suggested save path:
```
-.opencode/plans/YYYY-MM-DD-.md
+.agents/plans/YYYY-MM-DD-.md
```
Use today's date in the user's local timezone. The ``
slug is lowercase, hyphen-separated, and a short summary of the task
-(e.g. `add-batch-get-profiles-for-file-comments`). Create the
-`.opencode/plans/` directory if it does not exist.
+(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
+provides a target file path, announce that path instead of the default.
-IMPORTANT: The plan agent has write permission specifically for
-`.opencode/plans/` — always attempt the write. If the user explicitly provides
-a target file path, use that path instead of the default.
+End the response by suggesting the next steps: `/review-plan` to get a second
+opinion on the plan and `/implement-plan` to execute it.
### Plan Document Template
@@ -374,4 +375,6 @@ Before delivering the plan, confirm:
- [ ] Task dependencies are identified and ordered correctly
- [ ] No task is XL or larger — break it down instead
- [ ] Checkpoints exist after every 2-3 tasks
+- [ ] The response states the plan's path (saved or suggested) and suggests
+ `/review-plan` and `/implement-plan`
- [ ] The plan is ready for human review
diff --git a/.opencode/skills/refine-prompt/SKILL.md b/.agents/skills/refine-prompt/SKILL.md
similarity index 100%
rename from .opencode/skills/refine-prompt/SKILL.md
rename to .agents/skills/refine-prompt/SKILL.md
diff --git a/.agents/skills/resolve-git-conflicts/SKILL.md b/.agents/skills/resolve-git-conflicts/SKILL.md
new file mode 100644
index 0000000000..7fcfd53283
--- /dev/null
+++ b/.agents/skills/resolve-git-conflicts/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: resolve-git-conflicts
+description: Conflict resolution flow — understand the local git conflicts, present a resolution plan, and resolve them after the user approves it. Never continues the rebase. Use it when the repo has unresolved conflicts (rebase, merge, cherry-pick) or the user asks to resolve them.
+---
+
+# Resolve Git Conflicts
+
+Resolve conflicts in the local repository. The user handles finishing the
+rebase themselves — you must **never** run `git rebase --continue`,
+`git rebase --skip`, `git merge --continue`, or anything similar.
+
+## When to use
+
+- The repository has unresolved conflicts — during a rebase, merge, or
+ cherry-pick — whether the user asks about them or not.
+- The user asks to resolve conflicts, in any phrasing: "fix the merge
+ conflicts", "resolve these", "what's conflicting here?".
+
+## Phase 1 — Understand the problem (read-only)
+
+1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
+2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
+ - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
+ - Inspect both sides — `git show :` and `git show :` — plus `git log`/`git show` on the commits involved to understand intent.
+ - Identify what each side changed and why, and how they should be combined.
+
+## Phase 2 — Present the resolution plan
+
+3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
+ - What each side changed and why.
+ - Your proposed resolution and the reasoning behind it.
+ - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
+4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
+5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
+
+## Phase 3 — Execute
+
+6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
+
+## Phase 4 — Stage and verify
+
+7. **Stage every resolved file** with `git add `. Do not stage unrelated untracked files unless clearly part of the resolution.
+8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
+
+## Phase 5 — Report
+
+9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
diff --git a/.agents/skills/review-code/SKILL.md b/.agents/skills/review-code/SKILL.md
new file mode 100644
index 0000000000..bc50d56721
--- /dev/null
+++ b/.agents/skills/review-code/SKILL.md
@@ -0,0 +1,73 @@
+---
+name: review-code
+description: Code review flow — review a diff, PR, or code change, delegating the review to a subagent that follows the code-review-criteria skill. Use it when the user asks to review code or a PR, in any phrasing.
+---
+
+# Review Code
+
+Act as a senior software engineer and perform a thorough code review.
+
+## When to use
+
+- The user asks to review code, in any phrasing: "review this diff",
+ "review the PR", "check my changes", "code review" — or runs
+ `/review-code`.
+- A commit, branch, PR, or diff is ready and the user wants it assessed
+ before merge.
+
+## Instructions
+
+1. **Determine what is being reviewed** from the user context: a working-tree
+ diff, a commit range, a branch, a PR (number or URL), or specific files. If
+ the target is ambiguous, ask before reviewing.
+2. Delegate the review to the `general` subagent (via the task tool), unless the
+ user specifies another agent. Include in the prompt the
+ **`code-review-criteria`** skill name and all user context.
+3. When the subagent returns, output the review to the user verbatim. Do not
+ summarize it and do not act on its findings.
+4. Right after the review, suggest how to proceed based on the findings. These
+ are suggestions — the user decides:
+ - **Approve (no required changes):** say so — there is nothing to address.
+ - **Minor findings (nits):** applying them directly as-is is fine once the
+ review is done — no plan needed.
+ - **Substantive findings:** suggest `/make-a-plan` to make a plan to address
+ them.
+
+### Hard rule — read-only while reviewing
+
+This flow is read-only **for the duration of the review**: from the moment it
+starts until the user considers the review finished (including any feedback,
+questions, or clarifications about it). During that period, never fix,
+implement, edit files or create commits — not even "obvious" fixes derived from
+the findings. Once the user explicitly states the review is done (or moves on to
+a different task), this rule no longer applies and you act as a normal build
+agent again.
+
+## Instructions for the subagent
+
+1. Load the **`code-review-criteria`** skill and follow its process and output
+ format.
+2. Read `AGENTS.md` (if present) and follow its instructions for finding and
+ reading all related testing documentation from memories before reviewing.
+3. Return in your final message the COMPLETE review, verbatim, exactly as the
+ skill instructs it to be produced. Do not summarize it — include the full
+ structured review.
+
+### Strong rules for the subagent
+
+1. Do not invent problems. Every finding must be real and actionable.
+2. Read-only: do not modify any file and do not create a commit — reviewing
+ never writes.
+3. Be specific and constructive. "This could be better" is not helpful — explain
+ why and how.
+4. Prioritize by impact. One structural issue outweighs ten nits.
+5. Missing tests are an issue, not a suggestion. Report as a severity-tagged
+ finding — never as a recommendation.
+6. Skip generated files, lockfile-only changes, and unrelated modifications
+ unless they introduce security risks.
+
+## User context
+
+Extra context in the user's invocation (the message that triggered this skill)
+plays the role command arguments play elsewhere: for example, a PR number or
+URL, a commit range, specific files, or a different agent to run the review.
diff --git a/.agents/skills/review-plan/SKILL.md b/.agents/skills/review-plan/SKILL.md
new file mode 100644
index 0000000000..a74cb8469a
--- /dev/null
+++ b/.agents/skills/review-plan/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: review-plan
+description: Plan review flow — evaluate an implementation plan before it is executed, delegating the review to a subagent that follows the plan-review-criteria skill. Use it when the user asks to review a plan, in any phrasing.
+---
+
+# Review Plan
+
+Act as a senior software engineer and perform a thorough review of an
+implementation plan.
+
+## When to use
+
+- The user asks to review a plan, in any phrasing: "review this plan",
+ "does this plan look right?", "second opinion on the plan" — or runs
+ `/review-plan`.
+- A plan was just produced (typically by `/make-a-plan`) and the user
+ wants it evaluated before executing it.
+
+## Instructions
+
+1. **Determine the plan under review** from the session context (for example, a
+ plan just produced by `/make-a-plan`) or from a plan file path given by the
+ user (typically under `.agents/plans/`). If a file path is given, read the
+ file first so the complete plan is in context.
+2. Delegate the review to the `general` subagent (via the task tool), unless the
+ user specifies another agent. Include in the prompt the
+ **`plan-review-criteria`** skill name and all user context.
+3. When the subagent returns, output the review to the user verbatim. Do not
+ summarize it and do not act on its findings.
+4. Right after the review, suggest the next step based on the verdict. These
+ are suggestions — the user decides, and any instruction overrides them:
+ - **Approve** → suggest `/implement-plan` to execute it.
+ - **Request changes** → suggest `/make-a-plan` to make a plan to address the
+ findings.
+
+### Hard rule — read-only while reviewing
+
+This flow is read-only **for the duration of the review**: from the moment it
+starts until the user considers the review finished (including any feedback,
+questions, or clarifications about it). During that period, never fix,
+implement, edit files or create commits — not even "obvious" fixes derived from
+the findings. Once the user explicitly states the review is done (or moves on to
+a different task), this rule no longer applies and you act as a normal build
+agent again.
+
+## Instructions for the subagent
+
+1. Load the **`plan-review-criteria`** skill and follow its process and output
+ format.
+2. Read `AGENTS.md` (if present) and follow its instructions for finding and
+ reading all related documentation and testing memories before reviewing.
+3. Return in your final message the COMPLETE review, verbatim, exactly as the
+ skill instructs it to be produced. Do not summarize it — include the full
+ structured review.
+
+### Strong rules for the subagent
+
+1. Do not invent problems. Every finding must be real and actionable.
+2. Read-only: do not modify any file and do not create a commit — reviewing
+ never writes.
+3. Be specific and constructive. "This could be better" is not helpful — explain
+ why and how.
+4. Prioritize by impact. One structural issue outweighs ten nits.
+5. Judge the plan as the implementer would: every task executable without
+ guessing, ordering follows the dependency graph, risks named.
+
+## User context
+
+Extra context in the user's invocation (the message that triggered this skill)
+plays the role command arguments play elsewhere: for example, a plan file path
+to review, or a different agent to run the review.
diff --git a/.opencode/skills/ripgrep/SKILL.md b/.agents/skills/ripgrep/SKILL.md
similarity index 95%
rename from .opencode/skills/ripgrep/SKILL.md
rename to .agents/skills/ripgrep/SKILL.md
index 31c3a83d5e..1b028d8c58 100644
--- a/.opencode/skills/ripgrep/SKILL.md
+++ b/.agents/skills/ripgrep/SKILL.md
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id
Fast, smart recursive search. Respects `.gitignore` by default.
+## When to use
+
+- Searching file contents across the repo for regex patterns — the
+ default code search, respects `.gitignore`.
+
## Quick Start
### Basic search
diff --git a/.opencode/skills/security-and-hardening/SKILL.md b/.agents/skills/security-and-hardening/SKILL.md
similarity index 100%
rename from .opencode/skills/security-and-hardening/SKILL.md
rename to .agents/skills/security-and-hardening/SKILL.md
diff --git a/.opencode/skills/ste/SKILL.md b/.agents/skills/ste/SKILL.md
similarity index 95%
rename from .opencode/skills/ste/SKILL.md
rename to .agents/skills/ste/SKILL.md
index a53456ccf0..67e474ef6b 100644
--- a/.opencode/skills/ste/SKILL.md
+++ b/.agents/skills/ste/SKILL.md
@@ -9,6 +9,13 @@ Apply the ASD-STE100 standard to all prose you produce in this task. Do not anno
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
+## When to use
+
+Only when the user explicitly invokes it: they type `/ste`, or say "use
+the ste skill" / "apply ASD-STE100". Requests like "simplify this",
+"make it clearer", or "shorter sentences" do NOT invoke it — respond
+normally unless it is named.
+
## Step 0 — Classify the text
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
diff --git a/.opencode/skills/ste/references/examples.md b/.agents/skills/ste/references/examples.md
similarity index 100%
rename from .opencode/skills/ste/references/examples.md
rename to .agents/skills/ste/references/examples.md
diff --git a/.opencode/skills/ste/references/word-substitutions.md b/.agents/skills/ste/references/word-substitutions.md
similarity index 100%
rename from .opencode/skills/ste/references/word-substitutions.md
rename to .agents/skills/ste/references/word-substitutions.md
diff --git a/.opencode/skills/taiga/SKILL.md b/.agents/skills/taiga/SKILL.md
similarity index 94%
rename from .opencode/skills/taiga/SKILL.md
rename to .agents/skills/taiga/SKILL.md
index e63788698c..a5f32565a1 100644
--- a/.opencode/skills/taiga/SKILL.md
+++ b/.agents/skills/taiga/SKILL.md
@@ -11,6 +11,12 @@ Fetch information from Taiga public API for the **Penpot** project
**No authentication required** — only public project data is accessed.
+## When to use
+
+- The user asks about Penpot issues, user stories, or tasks tracked in
+ Taiga — fetch them via the public API (project id 345963), no
+ authentication needed.
+
## Prerequisites
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
diff --git a/.opencode/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md
similarity index 100%
rename from .opencode/skills/testing/SKILL.md
rename to .agents/skills/testing/SKILL.md
diff --git a/.opencode/skills/update-changelog/SKILL.md b/.agents/skills/update-changelog/SKILL.md
similarity index 100%
rename from .opencode/skills/update-changelog/SKILL.md
rename to .agents/skills/update-changelog/SKILL.md
diff --git a/.claude/skills b/.claude/skills
new file mode 120000
index 0000000000..2b7a412b8f
--- /dev/null
+++ b/.claude/skills
@@ -0,0 +1 @@
+../.agents/skills
\ No newline at end of file
diff --git a/.github/workflows/build-adhoc.yml b/.github/workflows/build-adhoc.yml
new file mode 100644
index 0000000000..327c449fc4
--- /dev/null
+++ b/.github/workflows/build-adhoc.yml
@@ -0,0 +1,44 @@
+name: _ADHOC
+
+run-name: >-
+ _ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }})
+
+on:
+ workflow_dispatch:
+ inputs:
+ gh_ref:
+ description: 'Branch/ref to build in penpot/penpot'
+ type: string
+ required: true
+ nitrate_ref:
+ description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)'
+ type: string
+ required: false
+ force:
+ description: 'Rebuild and overwrite even if already built/promoted'
+ type: boolean
+ required: false
+ default: false
+
+jobs:
+ build-bundle:
+ uses: ./.github/workflows/build-bundle.yml
+ secrets: inherit
+ with:
+ gh_ref: ${{ inputs.gh_ref }}
+ force: ${{ inputs.force }}
+
+ build-docker:
+ needs: build-bundle
+ uses: ./.github/workflows/build-docker.yml
+ secrets: inherit
+ with:
+ gh_ref: ${{ inputs.gh_ref }}
+ force: ${{ inputs.force }}
+
+ build-docker-admin-console:
+ uses: ./.github/workflows/build-docker-admin-console.yml
+ secrets: inherit
+ with:
+ gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
+ force: ${{ inputs.force }}
diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml
index b31450ac60..493a3e44e8 100644
--- a/.github/workflows/build-bundle.yml
+++ b/.github/workflows/build-bundle.yml
@@ -9,6 +9,11 @@ on:
type: string
required: true
default: 'develop'
+ force:
+ description: 'Rebuild and overwrite even if this version already exists in S3'
+ type: boolean
+ required: false
+ default: false
workflow_call:
inputs:
gh_ref:
@@ -16,6 +21,11 @@ on:
type: string
required: true
default: 'develop'
+ force:
+ description: 'Rebuild and overwrite even if this version already exists in S3'
+ type: boolean
+ required: false
+ default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
@@ -34,6 +44,8 @@ jobs:
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
+ sha: ${{ steps.vars.outputs.sha }}
+ commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -48,10 +60,12 @@ jobs:
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
+ echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
+ echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
# The uploaded zip carries its version as S3 metadata. If the
# existing object was already built from this same commit, the
- # whole build job is skipped.
+ # whole build job is skipped. `force` bypasses this check entirely.
- name: Check if this bundle is already built
id: check
env:
@@ -59,6 +73,16 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
+ if [ "${{ inputs.force }}" = "true" ]; then
+ echo "exists=false" >> $GITHUB_OUTPUT
+ {
+ echo "### 🔁 Bundle build forced"
+ echo ""
+ echo "\`force: true\` — skipping the S3 version check."
+ } >> "$GITHUB_STEP_SUMMARY"
+ exit 0
+ fi
+
EXISTING_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
@@ -117,6 +141,16 @@ jobs:
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
+ - name: Write step summary
+ run: |
+ {
+ echo "### ✅ Bundle built"
+ echo ""
+ echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
+ echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}"
+ echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
+ } >> "$GITHUB_STEP_SUMMARY"
+
# ── 3. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml
index 961ad1dca9..7346450831 100644
--- a/.github/workflows/build-develop.yml
+++ b/.github/workflows/build-develop.yml
@@ -1,7 +1,16 @@
name: _DEVELOP
+run-name: >-
+ _DEVELOP (develop @ ${{ github.sha }})
+
on:
workflow_dispatch:
+ inputs:
+ force:
+ description: 'Rebuild and overwrite even if already built/promoted'
+ type: boolean
+ required: false
+ default: false
schedule:
- cron: '16 5-20 * * 1-5'
@@ -15,6 +24,7 @@ jobs:
secrets: inherit
with:
gh_ref: "develop"
+ force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -22,9 +32,11 @@ jobs:
secrets: inherit
with:
gh_ref: "develop"
+ force: ${{ inputs.force || false }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "develop"
+ force: ${{ inputs.force || false }}
diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml
index b3f8384636..dbc28387b6 100644
--- a/.github/workflows/build-docker-admin-console.yml
+++ b/.github/workflows/build-docker-admin-console.yml
@@ -13,6 +13,11 @@ on:
type: string
required: false
default: 'develop'
+ force:
+ description: 'Rebuild and overwrite even if already built'
+ type: boolean
+ required: false
+ default: false
workflow_call:
inputs:
gh_ref:
@@ -24,6 +29,11 @@ on:
type: string
required: false
default: 'develop'
+ force:
+ description: 'Rebuild and overwrite even if already built'
+ type: boolean
+ required: false
+ default: false
secrets:
ORG_WORKFLOW_TOKEN:
description: 'Token with Actions write access on penpot-nitrate'
@@ -47,6 +57,7 @@ jobs:
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
-f gh_ref="$GH_REF" \
+ -f force="${{ inputs.force }}" \
-f caller_run_id="$DISTINCT_ID" \
-f caller_run_url="$CALLER_URL"
diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml
index b7bb794776..ade22b78d1 100644
--- a/.github/workflows/build-docker.yml
+++ b/.github/workflows/build-docker.yml
@@ -8,6 +8,11 @@ on:
type: string
required: true
default: 'develop'
+ force:
+ description: 'Rebuild and overwrite even if this sha is already promoted'
+ type: boolean
+ required: false
+ default: false
workflow_call:
inputs:
gh_ref:
@@ -15,6 +20,11 @@ on:
type: string
required: true
default: 'develop'
+ force:
+ description: 'Rebuild and overwrite even if this sha is already promoted'
+ type: boolean
+ required: false
+ default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
@@ -42,6 +52,7 @@ jobs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
+ commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -60,6 +71,7 @@ jobs:
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
+ echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
BUNDLE_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
@@ -71,7 +83,8 @@ jobs:
# The image set is a single block, so a single set-level check is
# enough: `promote` drops a marker object in S3 only after every
# image was built AND every branch tag was moved. Marker present
- # means there is nothing at all to do for this commit.
+ # means there is nothing at all to do for this commit. `force`
+ # bypasses this check entirely.
- name: Check if this image set is already built
id: check
env:
@@ -79,6 +92,21 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
+ if [ "${{ inputs.force }}" = "true" ]; then
+ echo "exists=false" >> $GITHUB_OUTPUT
+ mkdir -p "$BUNDLE_CACHE"
+ find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
+ ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
+ aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
+ mv "$ZIP.$$.tmp" "$ZIP"
+ {
+ echo "### 🔁 Image set build forced"
+ echo ""
+ echo "\`force: true\` — skipping the S3 marker check."
+ } >> "$GITHUB_STEP_SUMMARY"
+ exit 0
+ fi
+
if aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
@@ -138,7 +166,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- # To avoid the “429 Too Many Requests” error when downloading
+ # To avoid the "429 Too Many Requests" error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
@@ -258,9 +286,16 @@ jobs:
run: |
echo "${{ github.run_id }}" | aws s3 cp - \
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
+
+ - name: Write step summary
+ run: |
{
echo "### ✅ Image set promoted"
echo ""
+ echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
+ echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}"
+ echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
+ echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml
index 1523e4d7df..c2e3dad823 100644
--- a/.github/workflows/build-staging.yml
+++ b/.github/workflows/build-staging.yml
@@ -1,7 +1,16 @@
name: _STAGING
+run-name: >-
+ _STAGING (staging)
+
on:
workflow_dispatch:
+ inputs:
+ force:
+ description: 'Rebuild and overwrite even if already built/promoted'
+ type: boolean
+ required: false
+ default: false
schedule:
- cron: '36 5-20 * * 1-5'
@@ -15,6 +24,7 @@ jobs:
secrets: inherit
with:
gh_ref: "staging"
+ force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -22,9 +32,11 @@ jobs:
secrets: inherit
with:
gh_ref: "staging"
+ force: ${{ inputs.force || false }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "staging"
+ force: ${{ inputs.force || false }}
diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml
index aa6a2b8357..bfa30fb67b 100644
--- a/.github/workflows/build-tag.yml
+++ b/.github/workflows/build-tag.yml
@@ -1,7 +1,16 @@
name: _TAG
+run-name: >-
+ _TAG (${{ github.ref_name }} @ ${{ github.sha }})
+
on:
workflow_dispatch:
+ inputs:
+ force:
+ description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)'
+ type: boolean
+ required: false
+ default: false
push:
tags:
- '*'
@@ -18,6 +27,7 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
+ force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -25,12 +35,14 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
+ force: ${{ inputs.force || false }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
+ force: ${{ inputs.force || false }}
notify:
name: Notifications
diff --git a/.gitignore b/.gitignore
index cc695a46e3..722f0e219d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,7 +103,7 @@ opencode.json
/.playwright-mcp
/.devenv/mcp/
/opencode.json
-/.opencode/plans
+/.agents/plans
/.opencode/reports
/.opencode/prompts
/.ci-logs
diff --git a/.opencode/commands/create-pr.md b/.opencode/commands/create-pr.md
new file mode 100644
index 0000000000..a6c21f7bb2
--- /dev/null
+++ b/.opencode/commands/create-pr.md
@@ -0,0 +1,10 @@
+---
+description: Create a PR for the current task branch or update an existing one — loads and follows the create-pr skill
+agent: build
+---
+
+Load the **`create-pr`** skill and follow it as your only instruction.
+
+## User input, overrides and additional context
+
+$ARGUMENTS
diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md
index 8ecd1bd537..eaf9d29fc3 100644
--- a/.opencode/commands/implement-plan.md
+++ b/.opencode/commands/implement-plan.md
@@ -1,39 +1,10 @@
---
-description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
+description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch, or commits on the current branch with "direct" — loads and follows the implement-plan skill
agent: build
---
-This command is run once a plan is ready (for example, from plan mode). Execute
-the plan already prepared in the current session context. Follow these steps in order.
+Load the **`implement-plan`** skill and follow it as your only instruction.
-## 1. Create the issue
+## User input, overrides and additional context
-Use the **`create-issue`** skill, following the *Creating Issues from Draft Body*
-flow in `mem:workflow/creating-issues`. Derive the issue title and body from the
-plan. Capture the new issue's number — call it **NNNN** (needed for the branch
-name and the commit reference).
-
-## 2. Create the branch
-
-Create and switch to a branch named after the issue:
-
-```
-git checkout -b issue-NNNN
-```
-
-(Replace NNNN with the issue number from step 1.)
-
-## 3. Execute the plan
-
-Implement the prepared plan from the session context. Work methodically, keeping
-changes focused on what the issue requires. Do not commit — the commit happens in
-step 4.
-
-## 4. Commit with the create-commit skill
-
-After the implementation is complete, load the **`create-commit`** skill and
-follow its workflow to commit the changes. Provide a brief summary of what was
-implemented and why, the issue reference (`issue-NNNN`), and the model name you
-are running as so the `AI-assisted-by` trailer is set correctly.
-
-Do not push. Pushing is handled separately by the user.
+$ARGUMENTS
diff --git a/.opencode/commands/make-a-plan.md b/.opencode/commands/make-a-plan.md
new file mode 100644
index 0000000000..27a2be5559
--- /dev/null
+++ b/.opencode/commands/make-a-plan.md
@@ -0,0 +1,10 @@
+---
+description: Investigate the chosen task, produce an implementation plan, and save it — loads and follows the make-a-plan skill
+agent: build
+---
+
+Load the **`make-a-plan`** skill and follow it as your only instruction.
+
+## User input, overrides and additional context
+
+$ARGUMENTS
diff --git a/.opencode/commands/resolve-git-conflicts.md b/.opencode/commands/resolve-git-conflicts.md
index 1b17ca0001..05b13d3a4b 100644
--- a/.opencode/commands/resolve-git-conflicts.md
+++ b/.opencode/commands/resolve-git-conflicts.md
@@ -1,40 +1,6 @@
---
-description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
+description: Resolve local git conflicts and stage the resolved files; never continues the rebase — loads and follows the resolve-git-conflicts skill
agent: build
---
-# Fix Git Conflicts
-
-Resolve conflicts in the local repository. The user handles finishing the
-rebase themselves — you must **never** run `git rebase --continue`,
-`git rebase --skip`, `git merge --continue`, or anything similar.
-
-## Phase 1 — Understand the problem (read-only)
-
-1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
-2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
- - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
- - Inspect both sides — `git show :` and `git show :` — plus `git log`/`git show` on the commits involved to understand intent.
- - Identify what each side changed and why, and how they should be combined.
-
-## Phase 2 — Present the resolution plan
-
-3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
- - What each side changed and why.
- - Your proposed resolution and the reasoning behind it.
- - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
-4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
-5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
-
-## Phase 3 — Execute
-
-6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
-
-## Phase 4 — Stage and verify
-
-7. **Stage every resolved file** with `git add `. Do not stage unrelated untracked files unless clearly part of the resolution.
-8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
-
-## Phase 5 — Report
-
-9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
+Load the **`resolve-git-conflicts`** skill and follow it as your only instruction.
diff --git a/.opencode/commands/review-code.md b/.opencode/commands/review-code.md
new file mode 100644
index 0000000000..4bea9ab7ff
--- /dev/null
+++ b/.opencode/commands/review-code.md
@@ -0,0 +1,10 @@
+---
+description: Code review — review a diff, PR, or code change — loads and follows the review-code skill
+agent: build
+---
+
+Load the **`review-code`** skill and follow it as your only instruction.
+
+## User input, overrides and additional context
+
+$ARGUMENTS
diff --git a/.opencode/commands/review-plan.md b/.opencode/commands/review-plan.md
new file mode 100644
index 0000000000..90bc6161a9
--- /dev/null
+++ b/.opencode/commands/review-plan.md
@@ -0,0 +1,10 @@
+---
+description: Plan review — evaluate an implementation plan before executing it — loads and follows the review-plan skill
+agent: build
+---
+
+Load the **`review-plan`** skill and follow it as your only instruction.
+
+## User input, overrides and additional context
+
+$ARGUMENTS
diff --git a/.opencode/skills/create-pr/SKILL.md b/.opencode/skills/create-pr/SKILL.md
deleted file mode 100644
index 4980048849..0000000000
--- a/.opencode/skills/create-pr/SKILL.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-name: create-pr
-description: Create or update a GitHub PR following Penpot conventions.
----
-
-# Skill: create-pr
-
-Create or update a GitHub PR. Read and follow:
-- `mem:workflow/creating-prs` — title format, description structure, writing principles
-- `mem:workflow/creating-commits` — commit type emojis
-
-## When to Use
-
-- Creating a new PR from a feature branch
-- Updating an existing PR's title or description to match conventions
-
-## Prerequisites
-
-- `gh` CLI authenticated (`gh auth status`)
-
-## Commands
-
-**Create:**
-
-```bash
-gh pr create --repo penpot/penpot --title "" --body-file /tmp/pr-body.md
-```
-
-**Update:**
-
-```bash
-gh pr edit --repo penpot/penpot --title "" --body-file /tmp/pr-body.md
-```
-
-**Verify:**
-
-```bash
-gh pr view --repo penpot/penpot --json title,body
-```
diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md
index 117e8ff464..26fcfe2971 100644
--- a/.serena/memories/critical-info.md
+++ b/.serena/memories/critical-info.md
@@ -14,7 +14,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
+- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
+ layout, `corepack use` sweep order, the stamp-missing-field and
+ ignored-builds gotchas, verification steps)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
+- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- 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.
@@ -70,6 +74,14 @@ module. You can read it from `mem:/core`
- `scripts/error-reports.mjs` — Query error reports via RPC API with token
authentication. Supports list/get operations with filtering and pagination.
See `mem:scripts/error-reports`.
+- `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm
+ workspaces (root, modules, member packages). Keeps the shared pnpm store
+ at `/.pnpm-store` unless `--store`; ignores `external/` and
+ `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
+- `scripts/ci` — CI orchestration script: runs lint, tests, and format
+ checks per module (`frontend backend common render-wasm exporter mcp
+ plugins library`). Logs go to `.ci-logs/`; read the log file on failure.
+ See `mem:scripts/ci`.
# Dependency graph
diff --git a/.serena/memories/scripts/ci.md b/.serena/memories/scripts/ci.md
new file mode 100644
index 0000000000..69d3fa6968
--- /dev/null
+++ b/.serena/memories/scripts/ci.md
@@ -0,0 +1,61 @@
+# CI (scripts/ci)
+
+`scripts/ci` runs CI-style checks — lint, tests, format — for one or more
+monorepo modules and prints a per-task summary. It is the local equivalent
+of CI; use it to verify changes before declaring work done.
+
+## When to use
+
+- After implementing or fixing code in a module: run its checks before
+ finishing (AGENTS.md: run the applicable lint and format checks).
+- When `common/` changed: validate its consumers too (frontend, backend,
+ exporter; see the dependency graph in `mem:critical-info`).
+- To fix formatting across a module (`--fix`) or repair delimiters
+ (`--paren-repair`) before linting.
+
+## How to use (CLI)
+
+Run from the repo root:
+
+```bash
+./scripts/ci MODULE... # lint + test + fmt per module
+./scripts/ci --all --no-test # lint + fmt on all modules
+./scripts/ci --lint frontend # lint only
+./scripts/ci --fix --no-test frontend # format files, skip tests
+./scripts/ci --paren-repair --all # fix delimiters in all Clojure modules
+./scripts/ci --dry-run --all # preview what would run
+```
+
+Modules: `frontend backend common render-wasm exporter mcp plugins library`.
+
+Flags:
+
+- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats
+ instead).
+- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` /
+ `--no-test` / `--no-fmt` drop one task from the default set.
+- `--paren-repair` runs only the delimiter repair — it wraps
+ `scripts/paren-repair` over each module's Clojure/CLJS sources; see
+ `mem:scripts/paren-repair`.
+- `--all` selects every module; `--exclude MOD` drops one (repeatable).
+- `--fail-fast` stops at the first failure; `--quiet` suppresses failure
+ output; `--dry-run` prints commands without running; `--clean` removes
+ the log directory.
+
+## Logs and exit codes
+
+- Full output of every task: `.ci-logs/-.log`.
+- On failure the script prints the last 30 lines; the final summary lists
+ every failed `module:task` with its log path.
+- Exit code 0 when all selected tasks passed, 1 otherwise.
+- Diagnose failures by reading the log file — never pipe test output
+ through filters (AGENTS.md hard rule).
+
+## Notes
+
+- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`,
+ `./test`, and `cargo fmt`.
+- Test tasks are long-running (backend: `clojure -M:dev:test`); use a
+ generous timeout when calling it from an agent shell.
+- Skill entry point: `.agents/skills/local-ci/SKILL.md`.
+- Testing principles and output discipline: `mem:testing`.
diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md
index 9aff0b50c1..54a2993fa8 100644
--- a/.serena/memories/workflow/creating-issues.md
+++ b/.serena/memories/workflow/creating-issues.md
@@ -351,5 +351,5 @@ gh issue view --repo penpot/penpot --json title
## See Also
- End-to-end orchestration entry point: the `create-issue` skill at
- `.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry
+ `.agents/skills/create-issue/SKILL.md`. The skill is a thin entry
point; this memory is the canonical home for all issue-creation rules.
diff --git a/.serena/memories/workflow/updating-pnpm.md b/.serena/memories/workflow/updating-pnpm.md
new file mode 100644
index 0000000000..9f4ba133df
--- /dev/null
+++ b/.serena/memories/workflow/updating-pnpm.md
@@ -0,0 +1,89 @@
+# Updating pnpm Across All Workspaces
+
+Canonical procedure. Run it from the repo root with the log redirected to a
+file (never pipe tool output through filters).
+
+## Layout facts
+
+- The repo has 11 pnpm workspaces, each with its own `pnpm-workspace.yaml`
+ and `pnpm-lock.yaml`: the repo root plus `backend`, `common`, `docs`,
+ `exporter`, `frontend`, `library`, `mcp`, `media-processor`, `plugins`,
+ and `render-wasm`.
+- Every package inside a module workspace (for example all `plugins/apps/*`
+ and `plugins/libs/*` packages) is a plain member of that module's
+ workspace. Members must not carry their own `pnpm-workspace.yaml` or
+ `pnpm-lock.yaml`; their dependencies resolve through the parent
+ workspace's lockfile.
+- One shared pnpm store for the whole repo: `/.pnpm-store`. Every
+ workspace yaml sets it explicitly: `storeDir: .pnpm-store` at the root,
+ `storeDir: ../.pnpm-store` in each module. pnpm resolves the value
+ against the workspace root, so all workspaces land on the same store.
+ Do not remove these lines: nested workspaces do not inherit settings,
+ and without them each workspace may resolve a different store.
+- The store survives `node_modules` cleans. It is content-addressed and
+ integrity-verified, so it cannot go stale; staleness lives in
+ node_modules. Only `scripts/clean-node-modules --store` removes it.
+- Every `package.json` (about 35 of them) must carry a `packageManager` field
+ with the identical `pnpm@+sha512.` value. Do not let them drift.
+- CI pins no pnpm version; workflows rely on corepack reading
+ `packageManager`. Fixing the fields fixes CI.
+
+## Procedure
+
+1. Resolve the target tag first and note the version. Example:
+ `npm view pnpm dist-tags --json` for `next-12` (latest 12.x). The tag
+ moves over time; always re-check.
+2. List every directory with a `package.json`, excluding `node_modules`
+ (`fd -H -t f package.json -E node_modules`). This list is the work set;
+ do not maintain a hand-written list.
+3. Run `corepack use pnpm@` in workspace roots first, then members.
+ `corepack use` stamps `packageManager` in the nearest package.json and
+ runs an install. Member runs repeat the workspace install; after the root
+ run they are quick no-ops.
+4. If a run fails, fix the cause (see gotchas) and re-run that directory.
+
+## Gotchas
+
+- `corepack use` only updates an existing `packageManager` field. If a
+ package.json lacks the field, corepack walks up to the nearest ancestor
+ that has one and stamps that file instead; the member stays unstamped.
+ After the sweep, assert every package.json carries the field. For a
+ missing one, insert the identical `pnpm@+sha512.` string,
+ then re-run `corepack use pnpm@` in that directory.
+- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
+ a placeholder scaffold into its `pnpm-workspace.yaml`:
+ `allowBuilds: esbuild: set this to true or false` plus
+ `ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
+ Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
+ then re-run.
+- `plugins/apps/composable-test-suite` once had its own
+ `pnpm-workspace.yaml` and acted as a nested workspace root. That state is
+ gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
+ so a nested one silently forks install and lockfile behavior. Do not
+ reintroduce it.
+- Expect metadata-only lockfile diffs when only the pnpm version moves:
+ the pnpm self-reference entries, plus a new `packageManagerDependencies`
+ section in lockfiles last written by older pnpm. Large diffs mean
+ re-resolution; inspect them before accepting.
+
+## Verification
+
+- Every `packageManager` field is byte-identical (same version and hash).
+- `pnpm --version` in each workspace prints the target version.
+- `pnpm install --frozen-lockfile` succeeds in each of the 11 workspaces.
+- `git diff` on lockfiles matches the expectations above.
+
+## Cleaning stale node_modules
+
+- `scripts/clean-node-modules` removes every workspace `node_modules`: the
+ repo root, all module workspaces, and all member packages. Use it when
+ installs misbehave after dependency changes: clean, reinstall, done.
+- Flags: `-n/--dry-run` lists without deleting; `--store` also removes the
+ shared pnpm store at `/.pnpm-store` (the next install re-downloads
+ what it held). `external/` (vendored dependency trees with their own
+ lifecycles) and `.opencode/` are always ignored.
+- The script never touches the pnpm store by default, so the reinstall
+ after cleaning reuses cached packages (zero downloads).
+- After cleaning, run `pnpm install` in each workspace root to restore the
+ development environment; `frontend` postinstall also reinstalls and
+ builds `plugins-runtime`.
diff --git a/AGENTS.md b/AGENTS.md
index 06507f18d1..bc2b4c9865 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,9 @@
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
- **Never amend a commit that has been pushed** unless the user explicitly asks.
If the user pushes, treat that commit as final from the agent's side.
+- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is
+ generated from GitHub milestones during the release process; update it only
+ via the `update-changelog` skill flow or on explicit user request.
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
This prevents hiding test failures. See `mem:testing` for details.
@@ -142,6 +145,6 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
-- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
+- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`.
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
diff --git a/CHANGES.md b/CHANGES.md
index 1618253d1b..1666868044 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,12 @@
## 2.19.0 (Unreleased)
+### :rocket: Epics and highlights
+
+- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
+- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
+- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
+
### :bug: Bugs fixed
- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305))
@@ -11,6 +17,20 @@
- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454))
- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156))
- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199))
+- Fix "Cannot assign to read only property 'toString'" error during text resize (by @makesomethingshit) [#10168](https://github.com/penpot/penpot/issues/10168) (PR: [#11521](https://github.com/penpot/penpot/pull/11521))
+- Fix plugin postMessage channel broadcasting messages to all plugins without origin validation [#10968](https://github.com/penpot/penpot/issues/10968) (PR: [#10970](https://github.com/penpot/penpot/pull/10970))
+- Fix MCP plugin page navigation while connected crashing the workspace (by @makesomethingshit) [#11001](https://github.com/penpot/penpot/issues/11001) (PR: [#11521](https://github.com/penpot/penpot/pull/11521))
+- Fix shortcut search never matching on key combination, only on action label [#11003](https://github.com/penpot/penpot/issues/11003) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
+- Fix Shift + special character key shortcut capturing the shifted character instead of the physical key [#11004](https://github.com/penpot/penpot/issues/11004) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
+- Fix reassigning the "Paste" shortcut not updating the UI or taking effect in the workspace [#11005](https://github.com/penpot/penpot/issues/11005) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
+- Fix font-size dropdown clipping multi-digit values in Firefox (by @0xTHAC0) [#11008](https://github.com/penpot/penpot/issues/11008) (PR: [#11162](https://github.com/penpot/penpot/pull/11162), [#11500](https://github.com/penpot/penpot/pull/11500))
+- Fix exporting shortcuts producing an invalid "toggle-fullscreen" entry that breaks re-import [#11032](https://github.com/penpot/penpot/issues/11032) (PR: [#11081](https://github.com/penpot/penpot/pull/11081))
+- Fix plugin API missing permission checks in tokens, shapes, variants, flows, layouts, and user identity [#11137](https://github.com/penpot/penpot/issues/11137) (PR: [#11139](https://github.com/penpot/penpot/pull/11139))
+- Fix library summary Redis cache keys omitting the tenant [#11407](https://github.com/penpot/penpot/issues/11407) (PR: [#11408](https://github.com/penpot/penpot/pull/11408))
+- Fix active theme name in the inspect tab displaying an id instead of the name [#11437](https://github.com/penpot/penpot/issues/11437) (PR: [#11439](https://github.com/penpot/penpot/pull/11439))
+- Fix triple-click not selecting the full line in text editor v3 [#11483](https://github.com/penpot/penpot/issues/11483) (PR: [#11493](https://github.com/penpot/penpot/pull/11493))
+- Fix pasted text losing formatting on last lines after resizing and adding new lines from the top [#11501](https://github.com/penpot/penpot/issues/11501) (PR: [#11503](https://github.com/penpot/penpot/pull/11503))
+- Fix variant property dropdown appearing empty and throwing an internal error when the component has no sibling variants [#11524](https://github.com/penpot/penpot/issues/11524) (PR: [#11499](https://github.com/penpot/penpot/pull/11499))
### :sparkles: New features & Enhancements
@@ -19,9 +39,22 @@
- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
+- Add support for internal libraries and file sync for Design Tokens [#9334](https://github.com/penpot/penpot/issues/9334)
+- Warn self-hosted users when their Penpot version is outdated and surface what they're missing [#10497](https://github.com/penpot/penpot/issues/10497) (PR: [#11411](https://github.com/penpot/penpot/pull/11411))
+- Add dedicated RPC methods for plugin registry operations with permission validation [#10952](https://github.com/penpot/penpot/issues/10952) (PR: [#10957](https://github.com/penpot/penpot/pull/10957))
+- Document MCP and internal resolver environment variables (by @ShreyashAgare26) [#11318](https://github.com/penpot/penpot/issues/11318) (PR: [#11572](https://github.com/penpot/penpot/pull/11572))
+- Add tokens source indicator to assets tab [#11365](https://github.com/penpot/penpot/issues/11365) (PR: [#11439](https://github.com/penpot/penpot/pull/11439))
+- Export multiple fills to SVG [#11466](https://github.com/penpot/penpot/issues/11466) (PR: [#11467](https://github.com/penpot/penpot/pull/11467))
+- Add Penpot-specific board size presets (file thumbnail, template cover, plugin icon/cover) [#11561](https://github.com/penpot/penpot/issues/11561) (PR: [#11565](https://github.com/penpot/penpot/pull/11565))
## 2.18.0 (Unreleased)
+### :rocket: Epics and highlights
+
+- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
+- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
+- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
+
### :bug: Bugs fixed
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
@@ -360,7 +393,7 @@
### :rocket: Epics and highlights
-- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
+- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR: [#9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
### :sparkles: New features & Enhancements
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..70bf134a48
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+Read and follow the instructions in `AGENTS.md`.
+
+Treat `AGENTS.md` as the canonical project instruction file.
diff --git a/backend/package.json b/backend/package.json
index c6baf43f73..f29469a28e 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml
index a0b16465e9..dfe9e50563 100644
--- a/backend/pnpm-lock.yaml
+++ b/backend/pnpm-lock.yaml
@@ -1,3 +1,104 @@
+---
+lockfileVersion: '9.0'
+
+importers:
+
+ .:
+ configDependencies: {}
+ packageManagerDependencies:
+ pnpm:
+ specifier: 12.3.4
+ version: 12.3.4
+
+packages:
+
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
+ cpu: [x64]
+ os: [win32]
+
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
+ engines: {node: '>=18.*'}
+ hasBin: true
+
+snapshots:
+
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.darwin-x64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-x64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.win32-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.win32-x64@12.3.4':
+ optional: true
+
+ pnpm@12.3.4:
+ optionalDependencies:
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
+
+---
lockfileVersion: '9.0'
settings:
diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml
index b3fbd9192b..3ebe73f82d 100644
--- a/backend/pnpm-workspace.yaml
+++ b/backend/pnpm-workspace.yaml
@@ -1,2 +1,4 @@
+storeDir: ../.pnpm-store
+
minimumReleaseAgeExclude:
- brace-expansion@5.0.8 || 5.0.9
diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj
index b3fdc80d7e..4581e23c73 100644
--- a/backend/src/app/auth/oidc.clj
+++ b/backend/src/app/auth/oidc.clj
@@ -1037,7 +1037,7 @@
provider (prepare-organization-sso-provider cfg sso)
_info (get-info cfg provider state code)
session (session/get-session request)
- exp (ct/in-future {:minutes 15})]
+ exp (ct/in-future {:hours 4})]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 6f69047382..d338bb4813 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -58,6 +58,7 @@
:objects-storage-fs-directory "assets"
:auth-token-cookie-name "auth-token"
+ :auth-token-cookie-max-age-absolute (ct/duration {:days 30})
:assets-path "/internal/assets/"
:smtp-default-reply-to "Penpot "
@@ -206,6 +207,7 @@
[:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
+ [:auth-token-cookie-max-age-absolute {:optional true} ::ct/duration]
[:registration-domain-whitelist {:optional true} [::sm/set :string]]
[:email-verify-threshold {:optional true} ::ct/duration]
diff --git a/backend/src/app/graph/arrow.clj b/backend/src/app/graph/arrow.clj
index 55be1b8b64..f1e4715ba2 100644
--- a/backend/src/app/graph/arrow.clj
+++ b/backend/src/app/graph/arrow.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.arrow
"Bulk Ladybug ingest through in-memory Arrow.
diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj
index 277b1ae177..10e206941c 100644
--- a/backend/src/app/graph/debug.clj
+++ b/backend/src/app/graph/debug.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.debug
"In-memory Ladybug sessions for the debug graph console."
diff --git a/backend/src/app/graph/ingest.clj b/backend/src/app/graph/ingest.clj
index af0644ee4d..2e6d9bed1f 100644
--- a/backend/src/app/graph/ingest.clj
+++ b/backend/src/app/graph/ingest.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.ingest
"Penpot file -> Ladybug graph projection."
diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj
index 81d117c26e..44c4bd1f64 100644
--- a/backend/src/app/graph/ladybug.clj
+++ b/backend/src/app/graph/ladybug.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.ladybug
"Ladybug access layer for graph-backed Penpot.
diff --git a/backend/src/app/graph/meta.clj b/backend/src/app/graph/meta.clj
index 129babd1fa..074ff6461f 100644
--- a/backend/src/app/graph/meta.clj
+++ b/backend/src/app/graph/meta.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.meta
"`GraphMeta`: the graph's own account of who built it and from what.
diff --git a/backend/src/app/graph/projection/document.clj b/backend/src/app/graph/projection/document.clj
index 9d0eec6851..c513f3e13a 100644
--- a/backend/src/app/graph/projection/document.clj
+++ b/backend/src/app/graph/projection/document.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.projection.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
diff --git a/backend/src/app/graph/projection/transforms.clj b/backend/src/app/graph/projection/transforms.clj
index dc87392983..e37da3d477 100644
--- a/backend/src/app/graph/projection/transforms.clj
+++ b/backend/src/app/graph/projection/transforms.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.projection.transforms
"Derived graph links: edges a reader could compute from the projected
diff --git a/backend/src/app/graph/report.clj b/backend/src/app/graph/report.clj
index f026ff3a52..41c332013d 100644
--- a/backend/src/app/graph/report.clj
+++ b/backend/src/app/graph/report.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.report
(:require
diff --git a/backend/src/app/graph/schema.clj b/backend/src/app/graph/schema.clj
index 0aace14bea..63a84b577e 100644
--- a/backend/src/app/graph/schema.clj
+++ b/backend/src/app/graph/schema.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
diff --git a/backend/src/app/graph/schema/contract.clj b/backend/src/app/graph/schema/contract.clj
index 684a12dd2e..284ece55e8 100644
--- a/backend/src/app/graph/schema/contract.clj
+++ b/backend/src/app/graph/schema/contract.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.contract
"Deliberate choices in Penpot's graph schema, recorded as data.
diff --git a/backend/src/app/graph/schema/nodes.clj b/backend/src/app/graph/schema/nodes.clj
index c81802415d..be88fb7d7c 100644
--- a/backend/src/app/graph/schema/nodes.clj
+++ b/backend/src/app/graph/schema/nodes.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.nodes
"Single source of truth for graph node tables.
diff --git a/backend/src/app/graph/schema/projection.clj b/backend/src/app/graph/schema/projection.clj
index 4d0e969329..0302c722bd 100644
--- a/backend/src/app/graph/schema/projection.clj
+++ b/backend/src/app/graph/schema/projection.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.projection
"Derive Ladybug node column schemas from Penpot Malli sources.
diff --git a/backend/src/app/graph/schema/types.clj b/backend/src/app/graph/schema/types.clj
index a905d92dcc..a2787a7b89 100644
--- a/backend/src/app/graph/schema/types.clj
+++ b/backend/src/app/graph/schema/types.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.types
"Map Malli schemas to Ladybug column types.
diff --git a/backend/src/app/graph/schema/values.clj b/backend/src/app/graph/schema/values.clj
index e93b74f499..12988dfbc3 100644
--- a/backend/src/app/graph/schema/values.clj
+++ b/backend/src/app/graph/schema/values.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.values
"Shape a Penpot value into the plain data its Ladybug column type wants.
diff --git a/backend/src/app/graph/stats.clj b/backend/src/app/graph/stats.clj
index 06a4eb2330..19c5dc67d5 100644
--- a/backend/src/app/graph/stats.clj
+++ b/backend/src/app/graph/stats.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.stats
(:require
diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj
index cb32fe2d47..d69405e5fb 100644
--- a/backend/src/app/graph/sync.clj
+++ b/backend/src/app/graph/sync.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.sync
"Incremental Ladybug graph updates from Penpot file-change events."
diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj
index 914dfc169c..288fdc4396 100644
--- a/backend/src/app/http/session.clj
+++ b/backend/src/app/http/session.clj
@@ -36,6 +36,9 @@
;; Default age for automatic session renewal
(def default-renewal-max-age (ct/duration {:hours 6}))
+;; Default absolute maximum session duration
+(def default-cookie-max-age-absolute (ct/duration {:days 30}))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PROTOCOLS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -169,15 +172,19 @@
(defn- assign-token
[cfg session]
- (let [claims {:iss "authentication"
- :aud "penpot"
- :sid (:id session)
- :iat (:modified-at session)
- :uid (:profile-id session)
- :sso-provider-id (:sso-provider-id session)
- :sso-session-id (:sso-session-id session)}
- header {:kid 1 :ver 1}
- token (tokens/generate cfg claims header)]
+ (let [absolute-max-age (cf/get :auth-token-cookie-max-age-absolute default-cookie-max-age-absolute)
+ claims {:iss "authentication"
+ :aud "penpot"
+ :sid (:id session)
+ :iat (:modified-at session)
+ :uid (:profile-id session)
+ :sso-provider-id (:sso-provider-id session)
+ :sso-session-id (:sso-session-id session)}
+ claims (if (:created-at session)
+ (assoc claims :exp (ct/plus (:created-at session) absolute-max-age))
+ claims)
+ header {:kid 1 :ver 1}
+ token (tokens/generate cfg claims header)]
(assoc session :token token)))
(defn create-fn
@@ -353,15 +360,23 @@
or (updated_at is null and
created_at < ?::timestamptz)")
+(def ^:private
+ sql:delete-expired-v2
+ "DELETE FROM http_session_v2
+ WHERE created_at < ?::timestamptz")
+
(defn- collect-expired-tasks
[{:keys [::db/conn ::tasks/max-age]}]
(let [threshold (ct/minus (ct/now) max-age)
- result (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
- (db/get-update-count))]
+ result-legacy (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
+ (db/get-update-count))
+ result-v2 (-> (db/exec-one! conn [sql:delete-expired-v2 threshold])
+ (db/get-update-count))]
(l/dbg :task "gc"
:hint "clean http sessions"
- :deleted result)
- result))
+ :deleted-legacy result-legacy
+ :deleted-v2 result-v2)
+ (+ result-legacy result-v2)))
(defmethod ig/init-key ::tasks/gc
[_ {:keys [::tasks/max-age] :as cfg}]
diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj
index 989f59ef36..4d6b8eb8c6 100644
--- a/backend/src/app/rpc/commands/plugins.clj
+++ b/backend/src/app/rpc/commands/plugins.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.rpc.commands.plugins
(:require
diff --git a/backend/src/app/storage/pending_gc.clj b/backend/src/app/storage/pending_gc.clj
index 7b8f04ff13..df161f40c9 100644
--- a/backend/src/app/storage/pending_gc.clj
+++ b/backend/src/app/storage/pending_gc.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.storage.pending-gc
"A maintenance task that reclaims storage objects created in 'pending'
diff --git a/backend/src/app/tasks/demo_purge.clj b/backend/src/app/tasks/demo_purge.clj
index 429816c053..a77fb36e09 100644
--- a/backend/src/app/tasks/demo_purge.clj
+++ b/backend/src/app/tasks/demo_purge.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj
index 82696144db..7064c8f40c 100644
--- a/backend/src/app/tasks/telemetry.clj
+++ b/backend/src/app/tasks/telemetry.clj
@@ -17,7 +17,6 @@
[app.http.client :as http]
[app.main :as-alias main]
[app.setup :as-alias setup]
- [app.util.blob :as blob]
[app.util.json :as json]
[integrant.core :as ig]
[promesa.exec :as px]))
@@ -248,20 +247,16 @@
:props (or (some-> props db/decode-transit-pgobject) {})
:context (or (some-> context db/decode-transit-pgobject) {})}))
-(defn- encode-batch
- "Encode a sequence of event maps into a fressian+zstd base64 string
- suitable for JSON transport."
- ^String [events]
- (blob/encode-str events {:version 4}))
-
(defn send-event-batch
"Send a single batch of events to the telemetry endpoint. Returns
- true on success."
+ true on success. The events are sent as a plain vector of event
+ maps; the JSON encoder handles UUID and temporal types natively and
+ the receiver coerces them back to proper types."
[{:keys [::setup/props] :as cfg} batch]
(let [payload {:type :telemetry-events
:version (:full cf/version)
:instance-id (:instance-id props)
- :events (encode-batch batch)}
+ :events (vec batch)}
request {:method :post
:uri (cf/get :telemetry-uri)
:headers {"content-type" "application/json"}
diff --git a/backend/test/backend_tests/demo_test.clj b/backend/test/backend_tests/demo_test.clj
index da1cc342c9..66b8c04ddf 100644
--- a/backend/test/backend_tests/demo_test.clj
+++ b/backend/test/backend_tests/demo_test.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.demo-test
(:require
diff --git a/backend/test/backend_tests/graph_binder_gate_test.clj b/backend/test/backend_tests/graph_binder_gate_test.clj
index 508bf9d60d..84ad8c598c 100644
--- a/backend/test/backend_tests/graph_binder_gate_test.clj
+++ b/backend/test/backend_tests/graph_binder_gate_test.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.graph-binder-gate-test
"Binder gate for the incremental-sync statement templates.
diff --git a/backend/test/backend_tests/graph_sync_parity_test.clj b/backend/test/backend_tests/graph_sync_parity_test.clj
index c7eff4c34e..a0a07d4365 100644
--- a/backend/test/backend_tests/graph_sync_parity_test.clj
+++ b/backend/test/backend_tests/graph_sync_parity_test.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.graph-sync-parity-test
"Cold projection and incremental sync are two implementations of one mapping,
diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj
index 6ec83924be..e5f3f48863 100644
--- a/backend/test/backend_tests/http_middleware_test.clj
+++ b/backend/test/backend_tests/http_middleware_test.clj
@@ -277,6 +277,70 @@
(t/is (= (:id session) (:sid claims)))
(t/is (= (:id profile) (:uid claims)))))
+(t/deftest session-token-contains-exp-claim
+ (let [cfg th/*system*
+ manager (session/inmemory-manager)
+ profile (th/create-profile* 1)
+ session (->> (session/create-session manager {:profile-id (:id profile)
+ :user-agent "user agent"})
+ (#'session/assign-token cfg))
+ claims (tokens/decode cfg (:token session))
+ exp (:exp claims)]
+ (t/is (some? exp) "session token should contain :exp claim")
+ (t/is (ct/inst? exp) "exp should be an instant")))
+
+(t/deftest session-token-exp-based-on-created-at
+ (let [cfg th/*system*
+ manager (session/inmemory-manager)
+ profile (th/create-profile* 1)
+ session (->> (session/create-session manager {:profile-id (:id profile)
+ :user-agent "user agent"})
+ (#'session/assign-token cfg))
+ claims (tokens/decode cfg (:token session))
+ expected-exp (ct/plus (:created-at session) (ct/duration {:days 30}))]
+ (t/is (some? (:exp claims)) "session token should contain :exp claim")
+ (t/is (= (inst-ms (:exp claims))
+ (inst-ms expected-exp))
+ "exp should equal created-at + 30 days")))
+
+(t/deftest session-token-past-exp-is-rejected
+ (let [cfg th/*system*
+ manager (session/inmemory-manager)
+ profile (th/create-profile* 1)
+ session (->> (session/create-session manager {:profile-id (:id profile)
+ :user-agent "user agent"})
+ (#'session/assign-token cfg))
+ claims (tokens/decode cfg (:token session))
+ ;; Manually create a token with exp in the past
+ past-claims (assoc claims :exp (ct/minus (ct/now) (ct/duration {:days 1})))
+ header {:kid 1 :ver 1}
+ past-token (tokens/generate cfg past-claims header)]
+ (t/is (nil? (session/decode-token cfg past-token))
+ "token with exp in the past should be rejected")))
+
+(t/deftest session-renewal-preserves-original-exp
+ (let [cfg th/*system*
+ manager (session/inmemory-manager)
+ profile (th/create-profile* 1)
+ handler (-> (fn [req] req)
+ (#'session/wrap-authz {::session/manager manager})
+ (#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
+ :cookie (partial session/decode-token cfg)}))
+ session (->> (session/create-session manager {:profile-id (:id profile)
+ :user-agent "user agent"})
+ (#'session/assign-token cfg))
+ original-exp (:exp (tokens/decode cfg (:token session)))
+ ;; Force renewal by setting modified-at to 7 hours ago
+ old-session (assoc session :modified-at (ct/minus (ct/now) (ct/duration {:hours 7})))
+ response (handler (make-dummy-request {:cookies {"auth-token" (:token old-session)}}))
+ {:keys [token claims]} (get response ::http/auth-data)
+ new-exp (:exp claims)]
+ (t/is (some? original-exp) "original token should have :exp")
+ (t/is (some? new-exp) "renewed token should have :exp")
+ (t/is (= (inst-ms original-exp)
+ (inst-ms new-exp))
+ "renewed token should preserve original :exp, not extend it")))
+
(t/deftest parse-request-illegal-argument-exception
;; clojure.data.json raises IllegalArgumentException (case
;; fall-through) on several kinds of malformed input. The
diff --git a/backend/test/backend_tests/passwords_test.clj b/backend/test/backend_tests/passwords_test.clj
index 75a880a5de..9aa9e80f44 100644
--- a/backend/test/backend_tests/passwords_test.clj
+++ b/backend/test/backend_tests/passwords_test.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.passwords-test
(:require
diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj
index 3bda13fc61..b3561ade93 100644
--- a/backend/test/backend_tests/rpc_demo_test.clj
+++ b/backend/test/backend_tests/rpc_demo_test.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-demo-test
(:require
diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj
index 9850d90ba9..cb895d4395 100644
--- a/backend/test/backend_tests/rpc_plugins_test.clj
+++ b/backend/test/backend_tests/rpc_plugins_test.clj
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-plugins-test
(:require
diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj
index e12af0553b..a44f6efe74 100644
--- a/backend/test/backend_tests/tasks_telemetry_test.clj
+++ b/backend/test/backend_tests/tasks_telemetry_test.clj
@@ -12,7 +12,6 @@
[app.db :as db]
[app.loggers.audit :as audit]
[app.tasks.telemetry :as telemetry]
- [app.util.blob :as blob]
[app.util.json :as json]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -59,11 +58,6 @@
:cnt
long))
-(defn- decode-event-batch
- "Decode the base64+fressian+zstd event-batch sent to the mock."
- [b64-str]
- (blob/decode-str b64-str))
-
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; STATS / REPORT STRUCTURE TESTS (existing behaviour, extended)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -245,21 +239,19 @@
(t/is (not (contains? ev :ip-addr)))))))))
(t/deftest test-batch-encoding-is-decodable
- ;; Verify that encode-batch produces a blob that round-trips back
- ;; through blob/decode to the original data.
+ ;; Events are sent as a plain vector of raw event maps (no blob
+ ;; encoding): every batch must JSON round-trip unchanged, because
+ ;; the receiver coerces types from the plain JSON representation.
(let [events [{:name "navigate" :type "action" :source "telemetry"
:tracked-at (ct/now)}
{:name "create-file" :type "action" :source "telemetry"
:tracked-at (ct/now)}]
- ;; Call the private fn through the ns-mapped var
- encode (ns-resolve 'app.tasks.telemetry 'encode-batch)
- encoded (encode events)
- decoded (decode-event-batch encoded)]
- (t/is (string? encoded))
- (t/is (seq decoded))
- (t/is (= (count events) (count decoded)))
- (t/is (= "navigate" (:name (first decoded))))
- (t/is (= "create-file" (:name (second decoded))))))
+ encoded (json/encode-str {:events (vec events)})
+ decoded (json/decode encoded)]
+ (t/is (vector? (:events decoded)))
+ (t/is (= (count events) (count (:events decoded))))
+ (t/is (= "navigate" (:name (first (:events decoded)))))
+ (t/is (= "create-file" (:name (second (:events decoded)))))))
(t/deftest test-multiple-batches-when-many-events
;; Lower batch-size to 1 so that 3 events produce 3 separate
@@ -787,9 +779,13 @@
(t/is (= "telemetry-events" (name (:type body))))
(t/is (string? (:version body)))
(t/is (some? (:instance-id body)))
- ;; :events is a base64-encoded blob
- (t/is (string? (:events body)))
- (t/is (pos? (count (:events body))))))))))
+ ;; :events is a plain vector of raw event maps
+ (t/is (vector? (:events body)))
+ (t/is (pos? (count (:events body))))
+ (doseq [ev (:events body)]
+ (t/is (string? (:name ev)))
+ (t/is (string? (:source ev)))
+ (t/is (string? (:tracked-at ev))))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK BRANCH COVERAGE
diff --git a/common/package.json b/common/package.json
index acc9432642..4269da7ba6 100644
--- a/common/package.json
+++ b/common/package.json
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"type": "module",
"repository": {
"type": "git",
diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml
index 77f173a685..ef70c46056 100644
--- a/common/pnpm-lock.yaml
+++ b/common/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/common/pnpm-workspace.yaml b/common/pnpm-workspace.yaml
index a1c031fc33..7b3a5d3edf 100644
--- a/common/pnpm-workspace.yaml
+++ b/common/pnpm-workspace.yaml
@@ -1 +1,3 @@
+storeDir: ../.pnpm-store
+
minimumReleaseAge: 0
diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc
index 58cd5d970f..96e71a5fcb 100644
--- a/common/src/app/common/types/color.cljc
+++ b/common/src/app/common/types/color.cljc
@@ -72,6 +72,13 @@
[:map {:title "PlainColorAttrs"}
[:color schema:hex-color]])
+(def schema:image-transform
+ [:map {:title "ImageTransform" :closed true}
+ [:x {:optional true} ::sm/safe-number]
+ [:y {:optional true} ::sm/safe-number]
+ [:width {:optional true} ::sm/safe-number]
+ [:height {:optional true} ::sm/safe-number]])
+
(def schema:image
[:map {:title "ImageColor" :closed true}
[:width [::sm/int {:min 0 :gen/gen sg/int}]]
@@ -79,7 +86,8 @@
[:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text]
[:id ::sm/uuid]
[:name {:optional true} ::sm/text]
- [:keep-aspect-ratio {:optional true} :boolean]])
+ [:keep-aspect-ratio {:optional true} :boolean]
+ [:transform {:optional true} schema:image-transform]])
(def image-attrs
"A set of attrs that corresponds to image data type"
diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc
index 32f806568f..2f8ca65a6f 100644
--- a/common/src/app/common/types/fills/impl.cljc
+++ b/common/src/app/common/types/fills/impl.cljc
@@ -119,12 +119,15 @@
(defn write-image-fill
[offset buffer opacity image]
- (let [image-id (get image :id)
- image-width (get image :width)
- image-height (get image :height)
- alpha (mth/floor (* opacity 0xff))
- keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
- flags (bit-or keep-aspect-ratio 0x00)]
+ (let [image-id (get image :id)
+ image-width (get image :width)
+ image-height (get image :height)
+ alpha (mth/floor (* opacity 0xff))
+ keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
+ transform (get image :transform)
+ has-transform? (some? transform)
+ transform-flag (if has-transform? 0x02 0x00)
+ flags (bit-or keep-aspect-ratio transform-flag)]
(buf/write-byte buffer (+ offset 0) 0x03)
(buf/write-uuid buffer (+ offset 4) image-id)
(buf/write-byte buffer (+ offset 20) alpha)
@@ -132,6 +135,17 @@
(buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use)
(buf/write-int buffer (+ offset 24) image-width)
(buf/write-int buffer (+ offset 28) image-height)
+ (if has-transform?
+ (do
+ (buf/write-float buffer (+ offset 32) (double (get transform :x 0.0)))
+ (buf/write-float buffer (+ offset 36) (double (get transform :y 0.0)))
+ (buf/write-float buffer (+ offset 40) (double (get transform :width 1.0)))
+ (buf/write-float buffer (+ offset 44) (double (get transform :height 1.0))))
+ (do
+ (buf/write-float buffer (+ offset 32) 0.0)
+ (buf/write-float buffer (+ offset 36) 0.0)
+ (buf/write-float buffer (+ offset 40) 1.0)
+ (buf/write-float buffer (+ offset 44) 1.0)))
(+ offset FILL-U8-SIZE)))
(defn- write-metadata
@@ -208,28 +222,36 @@
:type type}})
3 ;; image fill
- (let [id (buf/read-uuid dbuffer (+ doffset 4))
- alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
- opacity (mth/precision (/ alpha 0xff) 2)
- flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
- ratio (boolean (bit-and flags 0x01))
- width (buf/read-int dbuffer (+ doffset 24))
- height (buf/read-int dbuffer (+ doffset 28))
- mtype (buf/read-short mbuffer (+ moffset 2))
- mtype (case mtype
- 0x01 "image/jpeg"
- 0x02 "image/png"
- 0x03 "image/gif"
- 0x04 "image/webp"
- 0x05 "image/svg+xml")]
+ (let [id (buf/read-uuid dbuffer (+ doffset 4))
+ alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
+ opacity (mth/precision (/ alpha 0xff) 2)
+ flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
+ ratio (not (zero? (bit-and flags 0x01)))
+ has-tf (not (zero? (bit-and flags 0x02)))
+ width (buf/read-int dbuffer (+ doffset 24))
+ height (buf/read-int dbuffer (+ doffset 28))
+ transform (when has-tf
+ {:x (buf/read-float dbuffer (+ doffset 32))
+ :y (buf/read-float dbuffer (+ doffset 36))
+ :width (buf/read-float dbuffer (+ doffset 40))
+ :height (buf/read-float dbuffer (+ doffset 44))})
+ mtype (buf/read-short mbuffer (+ moffset 2))
+ mtype (case mtype
+ 0x01 "image/jpeg"
+ 0x02 "image/png"
+ 0x03 "image/gif"
+ 0x04 "image/webp"
+ 0x05 "image/svg+xml")]
{:fill-opacity opacity
- :fill-image {:id id
- :width width
- :height height
- :mtype mtype
- :keep-aspect-ratio ratio
- ;; FIXME: we are not encodign the name, looks useless
- :name "sample"}}))]
+ :fill-image (cond-> {:id id
+ :width width
+ :height height
+ :mtype mtype
+ :keep-aspect-ratio ratio
+ ;; FIXME: we are not encodign the name, looks useless
+ :name "sample"}
+ (some? transform)
+ (assoc :transform transform))}))]
(if refs?
(let [ref-file (buf/read-uuid mbuffer (+ moffset 4))
diff --git a/common/src/app/common/types/path/fit.cljc b/common/src/app/common/types/path/fit.cljc
index 486822a79f..7c31359f7d 100644
--- a/common/src/app/common/types/path/fit.cljc
+++ b/common/src/app/common/types/path/fit.cljc
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.fit
"Curve fitting helpers."
diff --git a/common/src/app/common/types/path/selection.cljc b/common/src/app/common/types/path/selection.cljc
index 826047431b..203ed0e11d 100644
--- a/common/src/app/common/types/path/selection.cljc
+++ b/common/src/app/common/types/path/selection.cljc
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.selection
"Transforms selected path nodes and handlers."
diff --git a/common/src/app/common/types/tokens_status.cljc b/common/src/app/common/types/tokens_status.cljc
index c2953cc4c1..c341ee27e4 100644
--- a/common/src/app/common/types/tokens_status.cljc
+++ b/common/src/app/common/types/tokens_status.cljc
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.tokens-status
(:require
diff --git a/common/test/common_tests/files_migrations_0026_test.cljc b/common/test/common_tests/files_migrations_0026_test.cljc
index 92dde088e8..cc68368ee0 100644
--- a/common/test/common_tests/files_migrations_0026_test.cljc
+++ b/common/test/common_tests/files_migrations_0026_test.cljc
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-migrations-0026-test
(:require
diff --git a/common/test/common_tests/geom_image_bounds_resize_test.cljc b/common/test/common_tests/geom_image_bounds_resize_test.cljc
new file mode 100644
index 0000000000..a67fd67e13
--- /dev/null
+++ b/common/test/common_tests/geom_image_bounds_resize_test.cljc
@@ -0,0 +1,275 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns common-tests.geom-image-bounds-resize-test
+ (:require
+ #?(:clj [clojure.test :refer [deftest is testing]]
+ :cljs [cljs.test :refer-macros [deftest is testing]])
+ [app.common.math :as mth]
+ [app.common.schema :as sm]
+ [app.common.types.color :as clr]
+ [app.common.types.fills :as fills]
+ [app.common.types.fills.impl :as fills.impl]
+ [app.common.uuid :as uuid]))
+
+(deftest test-image-transform-schema
+ (testing "validates image with transform"
+ (let [img {:id (uuid/custom 1)
+ :width 400
+ :height 300
+ :mtype "image/png"
+ :keep-aspect-ratio true
+ :transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}]
+ (is (sm/validate clr/schema:image img))))
+
+ (testing "validates image without transform"
+ (let [img {:id (uuid/custom 1)
+ :width 400
+ :height 300
+ :mtype "image/png"
+ :keep-aspect-ratio true}]
+ (is (sm/validate clr/schema:image img))))
+
+ (testing "validates fill with image transform"
+ (let [fill {:fill-opacity 0.8
+ :fill-image {:id (uuid/custom 1)
+ :width 400
+ :height 300
+ :mtype "image/png"
+ :keep-aspect-ratio true
+ :transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}]
+ (is (sm/validate fills/schema:fill fill)))))
+
+(deftest test-image-fill-buffer-roundtrip
+ (testing "roundtrip image fill without transform"
+ (let [fill-vec [{:fill-opacity 0.9
+ :fill-image {:id (uuid/custom 1)
+ :width 800
+ :height 600
+ :mtype "image/jpeg"
+ :keep-aspect-ratio true
+ :name "sample"}}]
+ coerced (fills/from-plain fill-vec)
+ plain (into [] coerced)]
+ (is (= 1 (count plain)))
+ (is (= 0.9 (:fill-opacity (first plain))))
+ (is (= 800 (-> plain first :fill-image :width)))
+ (is (= 600 (-> plain first :fill-image :height)))
+ (is (true? (-> plain first :fill-image :keep-aspect-ratio)))
+ (is (nil? (-> plain first :fill-image :transform)))))
+
+ (testing "roundtrip image fill with transform"
+ (let [fill-vec [{:fill-opacity 0.75
+ :fill-image {:id (uuid/custom 2)
+ :width 1920
+ :height 1080
+ :mtype "image/webp"
+ :keep-aspect-ratio false
+ :name "sample"
+ :transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}]
+ coerced (fills/from-plain fill-vec)
+ plain (into [] coerced)
+ tf (-> plain first :fill-image :transform)]
+ (is (= 1 (count plain)))
+ (is (= 0.75 (:fill-opacity (first plain))))
+ (is (= 1920 (-> plain first :fill-image :width)))
+ (is (= 1080 (-> plain first :fill-image :height)))
+ (is (false? (-> plain first :fill-image :keep-aspect-ratio)))
+ (is (some? tf))
+ (is (mth/close? 0.25 (double (:x tf))))
+ (is (mth/close? -0.15 (double (:y tf))))
+ (is (mth/close? 1.5 (double (:width tf))))
+ (is (mth/close? 2.0 (double (:height tf)))))))
+
+(defn compute-bounds-resize-transform
+ "Mathematical model for independent image bounds resizing"
+ [{:keys [width height handler center? sx sy transform]}]
+ (let [w-new (* width sx)
+ h-new (* height sy)
+ [dx dy] (if ^boolean center?
+ [(/ (* width (- 1.0 sx)) 2.0)
+ (/ (* height (- 1.0 sy)) 2.0)]
+ [(case handler
+ (:left :bottom-left :top-left) (* width (- 1.0 sx))
+ 0.0)
+ (case handler
+ (:top :top-left :top-right) (* height (- 1.0 sy))
+ 0.0)])
+ nx0 (get transform :x 0.0)
+ ny0 (get transform :y 0.0)
+ nw0 (get transform :width 1.0)
+ nh0 (get transform :height 1.0)
+ nx' (/ (- (* nx0 width) dx) w-new)
+ ny' (/ (- (* ny0 height) dy) h-new)
+ nw' (/ nw0 sx)
+ nh' (/ nh0 sy)]
+ {:transform {:x nx' :y ny' :width nw' :height nh'}
+ :rendered-pixel-rect {:x (* nx' w-new)
+ :y (* ny' h-new)
+ :width (* nw' w-new)
+ :height (* nh' h-new)}}))
+
+(deftest test-handle-anchoring-mathematics
+ (testing "Right handle crop (shrinking width to 50%)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})]
+ (is (mth/close? 0.0 (-> res :transform :x)))
+ (is (mth/close? 0.0 (-> res :transform :y)))
+ (is (mth/close? 2.0 (-> res :transform :width)))
+ (is (mth/close? 1.0 (-> res :transform :height)))
+ ;; Rendered pixel content remains 200x100 starting at (0, 0)
+ (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Left handle crop (shrinking width to 50% from left)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})]
+ (is (mth/close? -1.0 (-> res :transform :x)))
+ (is (mth/close? 0.0 (-> res :transform :y)))
+ (is (mth/close? 2.0 (-> res :transform :width)))
+ (is (mth/close? 1.0 (-> res :transform :height)))
+ ;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!)
+ (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))
+
+ (testing "Top handle crop (shrinking height to 50% from top)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})]
+ (is (mth/close? 0.0 (-> res :transform :x)))
+ (is (mth/close? -1.0 (-> res :transform :y)))
+ (is (mth/close? 1.0 (-> res :transform :width)))
+ (is (mth/close? 2.0 (-> res :transform :height)))
+ ;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!)
+ (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Top-Left handle crop (shrinking both dimensions to 50%)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})]
+ (is (mth/close? -1.0 (-> res :transform :x)))
+ (is (mth/close? -1.0 (-> res :transform :y)))
+ (is (mth/close? 2.0 (-> res :transform :width)))
+ (is (mth/close? 2.0 (-> res :transform :height)))
+ (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Center resize (Alt modifier)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})]
+ (is (mth/close? -0.5 (-> res :transform :x)))
+ (is (mth/close? -0.5 (-> res :transform :y)))
+ (is (mth/close? 2.0 (-> res :transform :width)))
+ (is (mth/close? 2.0 (-> res :transform :height)))
+ (is (mth/close? -50.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? -25.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Bottom handle crop (shrinking height to 50% from bottom)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})]
+ (is (mth/close? 0.0 (-> res :transform :x)))
+ (is (mth/close? 0.0 (-> res :transform :y)))
+ (is (mth/close? 1.0 (-> res :transform :width)))
+ (is (mth/close? 2.0 (-> res :transform :height)))
+ (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Top-Right handle crop (shrinking both dimensions to 50%)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})]
+ (is (mth/close? 0.0 (-> res :transform :x)))
+ (is (mth/close? -1.0 (-> res :transform :y)))
+ (is (mth/close? 2.0 (-> res :transform :width)))
+ (is (mth/close? 2.0 (-> res :transform :height)))
+ (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Bottom-Left handle crop (shrinking both dimensions to 50%)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})]
+ (is (mth/close? -1.0 (-> res :transform :x)))
+ (is (mth/close? 0.0 (-> res :transform :y)))
+ (is (mth/close? 2.0 (-> res :transform :width)))
+ (is (mth/close? 2.0 (-> res :transform :height)))
+ (is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
+ (is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
+
+ (testing "Expanding bounds beyond original size (empty space exposure)"
+ (let [res (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})]
+ (is (mth/close? 0.0 (-> res :transform :x)))
+ (is (mth/close? 0.0 (-> res :transform :y)))
+ (is (mth/close? 0.5 (-> res :transform :width)))
+ (is (mth/close? 1.0 (-> res :transform :height)))
+ ;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space
+ (is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
+ (is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))))
+
+(deftest test-sequential-resize-operations
+ (testing "Sequential crops: crop right then crop left"
+ ;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1}
+ ;; Step 1: Crop right handle from 200 to 150 (sx = 0.75)
+ (let [step1 (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0})
+ tf1 (:transform step1)]
+ (is (mth/close? 0.0 (:x tf1)))
+ (is (mth/close? (/ 1.0 0.75) (:width tf1)))
+ ;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3)
+ (let [step2 (compute-bounds-resize-transform
+ {:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1})
+ tf2 (:transform step2)]
+ ;; The final 100x100 container has bitmap with width 200px
+ (is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width)))
+ ;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px
+ (is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x))))))
+
+ (testing "Bounds resize followed by standard proportional scaling"
+ ;; Step 1: Bounds resize crops width from 200 to 100
+ (let [step1 (compute-bounds-resize-transform
+ {:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})
+ tf1 (:transform step1)]
+ (is (mth/close? 2.0 (:width tf1)))
+ (is (mth/close? 1.0 (:height tf1)))
+
+ ;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x)
+ ;; During standard scale, normalized transform tf1 is kept constant!
+ (let [scaled-w (* 100.0 2.0)
+ scaled-h (* 100.0 2.0)
+ rendered-w (* (:width tf1) scaled-w)
+ rendered-h (* (:height tf1) scaled-h)]
+ ;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame!
+ (is (mth/close? 400.0 rendered-w))
+ (is (mth/close? 200.0 rendered-h))))))
+
+(deftest test-proportion-lock-invariance
+ (testing "Shape proportion-lock attribute remains unchanged"
+ (let [shape {:id (uuid/custom 10)
+ :type :rect
+ :width 200
+ :height 100
+ :proportion-lock true
+ :fills [{:fill-image {:id (uuid/custom 1)
+ :width 800
+ :height 600
+ :keep-aspect-ratio true}}]}
+ ;; Simulate bounds resize interaction
+ has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape)))
+ mod-pressed? true
+ bounds-resize? (and has-img? mod-pressed?)
+ lock-during-drag (if bounds-resize? false (:proportion-lock shape))]
+ ;; During drag, lock is bypassed (unless Shift is pressed)
+ (is (false? lock-during-drag))
+ ;; Shape's persistent setting is completely preserved
+ (is (true? (:proportion-lock shape))))))
diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc
index ab075151ee..d1ae2bbce5 100644
--- a/common/test/common_tests/runner.cljc
+++ b/common/test/common_tests/runner.cljc
@@ -29,6 +29,7 @@
[common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test]
[common-tests.geom-grid-test]
+ [common-tests.geom-image-bounds-resize-test]
[common-tests.geom-line-test]
[common-tests.geom-modif-tree-test]
[common-tests.geom-modifiers-test]
@@ -108,6 +109,7 @@
'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test
'common-tests.geom-grid-test
+ 'common-tests.geom-image-bounds-resize-test
'common-tests.geom-line-test
'common-tests.geom-modif-tree-test
'common-tests.geom-modifiers-test
diff --git a/common/test/common_tests/types/tokens_status_test.cljc b/common/test/common_tests/types/tokens_status_test.cljc
index d2ebb81dd3..a677f82cf9 100644
--- a/common/test/common_tests/types/tokens_status_test.cljc
+++ b/common/test/common_tests/types/tokens_status_test.cljc
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.tokens-status-test
(:require
diff --git a/docs/img/enterprise/enterprise-access-admin-console.webp b/docs/img/enterprise/enterprise-access-admin-console.webp
new file mode 100644
index 0000000000..2c0d98e28b
Binary files /dev/null and b/docs/img/enterprise/enterprise-access-admin-console.webp differ
diff --git a/docs/img/enterprise/enterprise-admin-console.webp b/docs/img/enterprise/enterprise-admin-console.webp
new file mode 100644
index 0000000000..91fc8cc728
Binary files /dev/null and b/docs/img/enterprise/enterprise-admin-console.webp differ
diff --git a/docs/img/enterprise/enterprise-membership-w.webp b/docs/img/enterprise/enterprise-membership-w.webp
new file mode 100644
index 0000000000..8ebad02478
Binary files /dev/null and b/docs/img/enterprise/enterprise-membership-w.webp differ
diff --git a/docs/img/enterprise/enterprise-module-sso.webp b/docs/img/enterprise/enterprise-module-sso.webp
new file mode 100644
index 0000000000..48d12cd322
Binary files /dev/null and b/docs/img/enterprise/enterprise-module-sso.webp differ
diff --git a/docs/img/enterprise/enterprise-organization-hierarchy-w.webp b/docs/img/enterprise/enterprise-organization-hierarchy-w.webp
new file mode 100644
index 0000000000..921358f22a
Binary files /dev/null and b/docs/img/enterprise/enterprise-organization-hierarchy-w.webp differ
diff --git a/docs/img/objects/line-arrow-tools.webp b/docs/img/objects/line-arrow-tools.webp
new file mode 100644
index 0000000000..4615e65cb8
Binary files /dev/null and b/docs/img/objects/line-arrow-tools.webp differ
diff --git a/docs/package.json b/docs/package.json
index 7d498ee015..208cd18089 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -39,5 +39,5 @@
"markdown-it-anchor": "^9.2.1",
"markdown-it-plantuml": "^1.4.1"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
index 5938203566..3527b910e8 100644
--- a/docs/pnpm-lock.yaml
+++ b/docs/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml
index bd2aa47620..b9b659dcc5 100644
--- a/docs/pnpm-workspace.yaml
+++ b/docs/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
minimumReleaseAgeExclude:
- undici@7.28.0 || 7.29.0
- js-yaml@3.15.0 || 4.3.0
diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md
index 1541f1d3b8..76426c22e5 100644
--- a/docs/technical-guide/configuration.md
+++ b/docs/technical-guide/configuration.md
@@ -660,6 +660,48 @@ PENPOT_INTERNAL_URI: http://penpot-frontend:8080
`http://penpot-frontend:8080` used in the docker-compose is a good default and
it is recommended to keep it unchanged.
+### MCP
+
+The MCP server lets AI agents read and edit Penpot files. It runs as a separate
+`penpot-mcp` container, and the frontend proxies the requests to it. Enable it with
+the corresponding flag:
+
+```bash
+PENPOT_FLAGS: [...] enable-mcp
+```
+
+With the flag enabled, the frontend container uses these variables to locate the MCP
+server:
+
+```bash
+# Frontend
+PENPOT_MCP_URI: http://penpot-mcp:4401
+PENPOT_MCP_URI_WS: http://penpot-mcp:4402
+```
+
+- `PENPOT_MCP_URI`: The URI of the MCP server, used for the streamable HTTP and SSE
+ endpoints.
+- `PENPOT_MCP_URI_WS`: The URI of the MCP server used for the websocket connection.
+
+The defaults match the service name used in the official `docker-compose.yaml`. Change
+them only if your MCP service has a different name or listens on other ports. Both
+variables are ignored when the `enable-mcp` flag is not set.
+
+### Internal resolver
+
+The frontend container resolves the backend, exporter and MCP service names with the
+DNS servers listed in its `/etc/resolv.conf`. If that autodetection does not work for
+your setup, set the resolver explicitly:
+
+```bash
+# Frontend
+PENPOT_INTERNAL_RESOLVER: 127.0.0.11
+```
+
+- `PENPOT_INTERNAL_RESOLVER`: The DNS server nginx uses to resolve the internal service
+ names. Defaults to the nameservers found in `/etc/resolv.conf`. `127.0.0.11` is the
+ embedded Docker DNS server; use the address of your own resolver on other setups.
+
## Other flags
There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant
@@ -670,6 +712,9 @@ for the user:
- enable-backend-api-doc: Enables the /api/doc
endpoint that lists all rpc methods available on backend
- disable-login-with-password: allows disable password based login form
+- enable-mcp: Enables the MCP server integration, so AI agents can
+ read and edit Penpot files. It also makes the frontend proxy the MCP endpoints to the
+ penpot-mcp service. Check the [MCP section][8] to get more detail.
- enable-prepl-server: enables PREPL server, used by manage.py and other additional
tools to communicate internally with Penpot backend. Check the [CLI section][5] to get more detail.
@@ -693,3 +738,4 @@ __Since version 2.0.0__
[5]: /technical-guide/getting-started/docker#using-the-cli-for-administrative-tasks
[6]: /technical-guide/integration/#webhooks
[7]: /technical-guide/integration/#access-tokens
+[8]: /mcp/
diff --git a/docs/user-guide/account-teams/comments.njk b/docs/user-guide/account-teams/comments.njk
index 4d5a2a769b..1cf4752094 100644
--- a/docs/user-guide/account-teams/comments.njk
+++ b/docs/user-guide/account-teams/comments.njk
@@ -1,6 +1,6 @@
---
title: Comments
-order: 4
+order: 6
desc: Learn how to import and export files in Penpot, the free, open-source design tool. Discover file formats, backups, sharing, and library management.
---
diff --git a/docs/user-guide/account-teams/enterprise-plan.njk b/docs/user-guide/account-teams/enterprise-plan.njk
new file mode 100644
index 0000000000..c9a82c81c4
--- /dev/null
+++ b/docs/user-guide/account-teams/enterprise-plan.njk
@@ -0,0 +1,35 @@
+---
+title: Enterprise plan
+order: 2
+desc: Learn how the Enterprise plan works in Penpot. Discover its features and how to use it within your organization.
+---
+
+Enterprise plan
+Penpot Enterprise gives organizations the tools to govern how design work happens across their teams: from creating a structured org and managing members, to applying fine-grained permissions and configuration through the Admin Console.
+
+What is Penpot Enterprise?
+
+Penpot Enterprise is the plan that unlocks organizational governance features. While Penpot remains free and unlimited as an open-source platform, Enterprise adds a layer of control on top: the ability to create Organizations, manage teams under it, and apply configuration settings that define what members can and cannot do.
+The organization owner is the user who creates the organization. They have exclusive access to the Admin Console and are responsible for configuring Modules.
+The key concepts you'll work with:
+
+ Organization : the top-level structure that groups one or more Teams under a shared governance layer.
+ Admin Console : the back-office interface where the org owner manages settings, teams and members.
+ Modules : the paid, configurable capabilities applied to an organization. Each Module consists of individual Controls (specific settings or restrictions).
+
+
+Subscribing to Enterprise
+ #
+
+To create an organization, you first need to upgrade to the Enterprise plan . Click "Create Organization" to begin. Once the subscription process is complete, you'll be redirected to the Admin Console to finish creating your organization.
+
+ Frequently asked questions
+ #
+
+
+ Can I have multiple organizations?
+ Yes. You can create more than one organization under a single Enterprise subscription and manage them from the Admin Console.
+
+ What happens if I cancel my Enterprise subscription?
+ Your organizations are deleted but not their teams. Governance settings no longer apply to any of them.
+
diff --git a/docs/user-guide/account-teams/index.njk b/docs/user-guide/account-teams/index.njk
index e03ed12133..d1d228d3ad 100644
--- a/docs/user-guide/account-teams/index.njk
+++ b/docs/user-guide/account-teams/index.njk
@@ -13,7 +13,19 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
Access your account settings and manage personal access tokens
-
+
+
+ Enterprise plan →
+ Learn how the Enterprise plan works and what it includes
+
+
+
+
+ Organizations →
+ Create and manage organizations, the Admin Console, and SSO
+
+
+
Teams →
Create and manage your teams
diff --git a/docs/user-guide/account-teams/organizations.njk b/docs/user-guide/account-teams/organizations.njk
new file mode 100644
index 0000000000..4c9d8ef777
--- /dev/null
+++ b/docs/user-guide/account-teams/organizations.njk
@@ -0,0 +1,227 @@
+---
+title: Organizations
+order: 3
+desc: Learn how Organizations work in Penpot Enterprise, creating one, the Admin Console, membership, Modules and Controls, and Single Sign-On (SSO).
+---
+
+Organizations
+An organization is the governance layer Penpot Enterprise adds on top of your teams. This section covers how to create and manage one: the Admin Console, membership, and the Modules and Controls used to configure it, including Single Sign-On (SSO).
+
+Creating an organization
+ #
+
+Creating an organization is as easy as giving it a name.
+Once the organization is created, you'll be taken to the Admin Console. At this point, the organization has one member: you, the owner.
+
+The Admin Console
+ #
+
+The Admin Console is the admin interface for your organization. Only the organization owner can access it.
+You can reach the Admin Console directly at /admin-console, or from any team dashboard in Penpot by opening the organization navigation menu and clicking the Admin Console link. If you own more than one organization, you can switch between them from within the Admin Console.
+
+
+
+
+
+
+
+The Admin Console includes:
+
+
+ Switch organization menu: To navigate between the organizations you own.
+ Organization settings: To change basic settings, such as renaming it or deleting it.
+ A "Go to files" button that returns you to a team dashboard.
+ Your user avatar , that expands into a full user menu.
+ Module: The control itself to configure.
+
+
+
+
+
+
+
+
+Organizations and teams
+ #
+
+An organization groups one or more Teams under a shared governance structure. Teams continue to work just as they do in standard Penpot, with the added layer that the org owner can apply configuration that affects all members across the organization's teams.
+How teams relate to organizations
+
+Teams inside an organization inherit the governance settings applied at the org level via the Admin Console. The structure is:
+
+
+
+
+
+Team members work within projects and files as usual. What changes under Enterprise is the org owner's ability to restrict or govern that work from the Admin Console.
+Within each team, the standard Penpot roles apply.
+
+Managing organization membership
+ #
+
+At launch, an organization has a single member: the owner. Additional members are brought in by being part of a team added to the organization, being invited to teams within the organization, or being directly invited to the organization by the owner.
+The Admin Console provides a unified view of all members across teams within the organization.
+
+
+
+
+
+
+
+Modules and Controls
+ #
+
+
+Modules are the configurable governance capabilities available to Enterprise organizations. Each Module is made up of one or more Controls: the individual settings that define who can do what, and where.
+Modules are configured from the Admin Console and apply organization-wide.
+
+
+
+
+ Modules
+ What it does
+
+
+
+
+ Single Sign-On (SSO)
+ Requires all org members to authenticate through your corporate identity provider before accessing the organization's teams and files.
+
+
+ Advanced permissions
+ Defines who can create, view, edit, administer, or share teams, projects, and files. Also controls who can invite new members to teams.
+
+
+
+
+Module: Advanced permissions
+ #
+
+
+ Advanced Permissions gives the organization owner fine-grained control over what members can do across all teams in the organization. Rather than relying on the default Penpot team roles alone, this module lets you restrict or open up specific actions at the organization level.
+
+ What it controls
+
+ The Advanced Permissions module is made up of individual Controls. Each Control governs a specific action, and each has a set of options to choose from. The selected option becomes the rule for the entire organization.
+
+ How to configure it
+
+ Advanced Permissions is configured from the Admin Console. Changes apply to all teams within the organization immediately.
+
+ Open the Admin Console.
+ Select Advanced Permissions from the left sidebar.
+ For each Control, select the option that fits your governance policy.
+ Changes take effect right away. There is no publish or save step.
+
+
+ How it relates to team roles
+
+ Advanced Permissions works on top of the standard Penpot team roles (Viewer, Editor, Admin, Owner). It does not replace them. Think of it as a ceiling: even if a member's team role would normally allow an action, an Advanced Permissions Control can prevent it organization-wide.
+ For example, if “New team members” is set to "Organization members only," a team owner who would normally be able to invite anyone will find that option restricted to people who are already part of the organization.
+
+ Default behavior
+
+ When an organization is first created, all controls are configured with the most permissive setting; the same setting is used by all teams that are not part of any organization. No behavior changes until you actively configure a Control.
+
+Module: Single Sign-On (SSO)
+ #
+
+
+ Single Sign-On lets you require all members of your organization to authenticate through your corporate identity provider (IdP) before accessing any of the organization's teams and files.
+ SSO applies to teams and files only. The Admin Console is always accessible without SSO, so you can always reach your configuration to adjust or deactivate it, even if your own directory entry changes.
+
+ Configuring your identity provider
+ Before setting up SSO in Penpot, you need to register Penpot as an application in your identity provider. The steps vary by provider, but you will always need to set the following callback URL in your IdP configuration:
+ https://<your-penpot-domain>/api/auth/oidc/callback
+ Your IdP will then give you a Client ID and Client Secret to use in Penpot. Once you have those:
+
+ Open the Admin Console.
+ Select SSO Config .
+ Choose your provider and fill in the fields.
+
+
+
+
+
+
+
+
+ Generic authentication (OpenID Connect)
+ Use this option for any identity provider that supports the OIDC protocol, such as Okta, Keycloak, or Auth0.
+
+ Issuer / Authority URL: base URL of your OIDC provider, used to autodiscover endpoints
+ Client ID: client identifier assigned by your provider
+ Client Secret: client secret assigned by your provider
+
+
+ Azure Active Directory (OpenID Connect)
+
+ Issuer / Authority URL: https://login.microsoftonline.com/<your-tenant-id>/v2.0/
+ Client ID: Application (client) ID from your Azure app registration
+ Client Secret: client secret value from your Azure app registration
+
+
+ Google (OAuth)
+
+ Client ID: client identifier from your Google Cloud OAuth 2.0 credentials
+ Client Secret: client secret from your Google Cloud OAuth 2.0 credentials
+
+
+ When all fields are filled, click Activate SSO . Penpot will run a test connection against your IdP. If the connection fails, your draft is kept and no changes are applied.
+ If the test passes, a confirmation dialog will appear. It will warn you that members not in your directory will lose access to the organization's teams once SSO is active. Review your member list if needed, then confirm. SSO becomes active immediately.
+
+ What happens to existing sessions
+ When SSO is activated, any member who is currently inside one of the organization's teams is cut off immediately and sent through the SSO login. This does not log them out of Penpot entirely. They can still reach teams that do not belong to your organization without re-authenticating.
+ SSO sessions last 4 hours. When a session expires, members are routed through the SSO login again. If they are still in the directory, they are signed back in immediately.
+
+ Editing an active configuration
+ While SSO is active, you can edit any field. As soon as you make a change, Apply changes and Discard changes buttons appear. Discarding restores every field to the current live configuration. Applying runs the same test connection as the initial setup, without a confirmation dialog. If the connection fails, your live configuration is not touched. Changes may take up to 5 minutes to apply for members who are currently working in a file.
+
+ Deactivating SSO
+ Click Deactivate SSO and confirm. The configuration is preserved as a draft so you can reactivate it later without re-entering your credentials. Members are notified by email the first time SSO is activated. If you deactivate and reactivate within 24 hours, the notification is not re-sent.
+
+ For your members
+ Members do not need to do anything to prepare. When they next try to access the organization's teams, they will be asked to authenticate through your IdP. If they are already signed in through that provider, the step is skipped automatically.
+ Org membership still requires an invitation from you. Being in the directory alone does not grant access to Penpot or to your organization.
+ If a member is not in your directory, they remain an org member but cannot enter the organization's teams until they are added. A single email is sent to all current members and pending invitees when SSO is first activated, explaining what changed and who to contact if they cannot get in.
+
+ Frequently asked questions
+ #
+
+
+ Can a team belong to more than one organization?
+ No. A team belongs to a single organization.
+
+ Can non-owners access the Admin Console?
+ No. Access to the Admin Console is currently exclusive to the organization owner.
+
+ What is the organization name used for?
+ It's the human-readable name used to identify your organization in the UI, in emails, and in URLs. It is not your official billing name. You can change it at any time without affecting navigation or functionality.
+
+ Do Advanced Permissions replace the standard Penpot team roles?
+ No. Advanced Permissions work on top of the existing roles (Viewer, Editor, Admin, Owner). They add an organization-wide ceiling on what any role can do, but they do not change how roles work within a team.
+
+ What is the default behavior when I first create an organization?
+ All Controls are set to their most permissive option. Nothing changes until you actively configure a Control.
+
+ What happens to existing team admins if I change a Control that restricts their permissions?
+ The restriction applies immediately. An admin who could previously perform an action will no longer be able to do so as soon as the Control is changed, with no grace period.
+
+ Is this the same as the authentication providers in Penpot's self-hosting configuration?
+ No. Penpot's self-hosted configuration lets server administrators enable login methods (Google, GitHub, GitLab, OIDC) at the instance level, so users can sign in to Penpot itself with those providers. That is a server-level setting managed by whoever runs the infrastructure.
+ The SSO module in Enterprise is different in scope and purpose. It is configured by you, the organization owner, from the Admin Console, and it governs access to your organization's teams and files specifically. It does not change how users log in to Penpot as a platform, only whether they need to pass through your corporate identity provider to reach your organization's content.
+
+ Does SSO affect the Admin Console?
+ No. The Admin Console is always accessible without SSO, regardless of your configuration. This ensures you can always reach your settings to adjust or deactivate SSO, even if something changes on the directory side.
+
+ What happens if my identity provider goes down while SSO is active?
+ Current sessions continue until they expire. The next time a member tries to authenticate through your IdP and the provider is unreachable, the login will fail. There is no automatic bypass. Since the Admin Console is outside SSO, you can still reach your configuration to deactivate SSO if needed.
+
+ Can I use the same identity provider for more than one organization?
+ Yes. Two different organizations, and the Penpot instance itself, can share the same IdP. A successful SSO login never grants org membership on its own, so there is no risk of cross-organization access. Membership always requires an explicit invitation.
+
+ What if a member is not in my directory?
+ They remain an org member and keep their Penpot account, but they cannot enter the organization's teams until they are added to the directory. We send them an email when SSO is first activated explaining the situation and telling them to contact you.
+
+ Does accepting an invitation automatically give someone access to my org's teams?
+ Only if they are also in your directory. An invitee can register and accept the invitation, but if they are not in the directory, they become an org member without being able to enter the teams. Acceptance is never blocked on that basis.
diff --git a/docs/user-guide/account-teams/projects-files.njk b/docs/user-guide/account-teams/projects-files.njk
index 02f1afd38c..b4befb2ad8 100644
--- a/docs/user-guide/account-teams/projects-files.njk
+++ b/docs/user-guide/account-teams/projects-files.njk
@@ -1,6 +1,6 @@
---
title: Projects and Files
-order: 3
+order: 5
desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items.
---
@@ -56,6 +56,10 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro
When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.
+Grid and list view
+In the dashboard header you can switch how files are shown: a thumbnail Grid view or a compact List view . The choice is kept in the browser and applies both to the team files view and inside a project.
+List view shows the file name, whether the file is a shared library, the last modification time, and the same options menu as the file cards.
+
Edit a file
To rename a file, right-click on the file card in the dashboard and select Rename , or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.
diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk
index 550cf81390..64c39376d3 100644
--- a/docs/user-guide/account-teams/teams.njk
+++ b/docs/user-guide/account-teams/teams.njk
@@ -1,6 +1,6 @@
---
title: Teams
-order: 2
+order: 4
desc: Manage teams and roles with Penpot's collaboration features! Learn how to manage teams, roles (Viewer, Editor, Admin, Owner), send invites and use webhooks.
---
diff --git a/docs/user-guide/designing/color-stroke.njk b/docs/user-guide/designing/color-stroke.njk
index 012b45b264..bd1cab3000 100644
--- a/docs/user-guide/designing/color-stroke.njk
+++ b/docs/user-guide/designing/color-stroke.njk
@@ -122,8 +122,14 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
+Stroke to path
+A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.
+Stroke to path turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.
+To convert a stroke, select a layer that has one, open the layer menu, and choose Stroke to path . Penpot creates a new path from each stroke (named with " (stroke)") and removes the strokes from the original layer.
+Stroke to path is currently available when WebGL rendering is enabled.
+
Stroke Caps
-Ever needed an arrow to point something? You can style the ends of any open paths selecting different styles for each end of an open path.
+Ever needed an arrow to point to something? You can style the ends of any open paths selecting different styles for each end of an open path. You can also start from the Arrow tool , which already applies a triangle cap.
diff --git a/docs/user-guide/designing/layers.njk b/docs/user-guide/designing/layers.njk
index 2c55a7a7ca..b46bc11f90 100644
--- a/docs/user-guide/designing/layers.njk
+++ b/docs/user-guide/designing/layers.njk
@@ -140,7 +140,7 @@ Penpot allows you to decide if the fill of an artboard will be shown in exports,
Rectangles and ellipses
Rectangle and ellipses are two basic “primitive” geometric shapes that are useful when starting
a design.
-The shortcut keys are E for ellipses and R for rectangles.
+The shortcut keys are E for ellipses and R for rectangles. Both tools live in the shapes flyout of the toolbar, together with line and arrow.
To find out more about how to edit and modify these shapes go to Layer basics .
@@ -151,13 +151,24 @@ a design.
Text
Text layers are how you add copy to your designs in Penpot. If you want to go deeper into fonts, typography and advanced text options, check the dedicated Text & Typography section.
+Lines and arrows
+Use the Line and Arrow tools to draw a two-point path in one drag. They live in the shapes flyout of the toolbar, together with rectangle and ellipse.
+
+ Line (L ): click and drag. Hold Shift/⇧ to snap the angle in 15 degree increments.
+ Arrow : the same gesture, with a triangle arrow cap on the end. Pick it from the shapes flyout. You can change or remove the cap from the stroke caps .
+
+The result is a regular path, so you can edit its nodes and stroke like any other path.
+
+
+
+
Curves (freehand)
The curve tool allows a path to be created directly in a freehand mode.
-Select the curve tool by clicking on the icon at the toolbar or pressing Shift/⇧ + c .
+Select the curve tool from the free-draw flyout in the toolbar or press Shift/⇧ + C .
The path created will contain a lot of points, but it is edited the same way as any other curve.
Paths (bezier)
-A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path you have to select the path tool by clicking on the icon at the toolbar or pressing P . Then you have two ways to create the path:
+A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press P . Then you have two ways to create the path:
Click to create a new corner node.
Click and drag to create a curved node.
@@ -211,7 +222,7 @@ You can choose to edit individual nodes or create new ones. Press Esc
Layer actions
Create
-To create a layer you have to select the type of layer by clicking the selected tool (board, rectangle, ellipse, text, image, path or curve) at the toolbar. Then you usually have to click and drag your mouse on the viewport.
+To create a layer, pick a tool from the toolbar. Board, text, and image stay on the toolbar. Rectangle, ellipse, line, and arrow are in the shapes flyout. Path and curve are in the free-draw flyout. Then you usually click and drag on the viewport.
Hold Shift/⇧ while creating an ellipse or a rectangle to maintain equal width and height.
diff --git a/docs/user-guide/designing/text-typo.njk b/docs/user-guide/designing/text-typo.njk
index 585a11b7cb..3317821ff2 100644
--- a/docs/user-guide/designing/text-typo.njk
+++ b/docs/user-guide/designing/text-typo.njk
@@ -32,7 +32,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe
- Font family. Penpot includes by default the Google Fonts cataloge. You can also install your own fonts .
+ Font family. Penpot includes by default the Google Fonts cataloge. You can also install your own fonts . In the font selector, each family name is shown in its own typeface so you can compare fonts before applying one. The name is the preview. While a font loads, you may briefly see the interface font instead. The preview uses a default variant, not every weight or style of the family.
Font size.
Font type.
Line height (in pixels).
diff --git a/docs/user-guide/designing/workspace-basics.njk b/docs/user-guide/designing/workspace-basics.njk
index e87ff0d740..c0547d4cbc 100644
--- a/docs/user-guide/designing/workspace-basics.njk
+++ b/docs/user-guide/designing/workspace-basics.njk
@@ -43,6 +43,15 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,
+Select and delete several pages
+In the Pages panel you can select more than one page and delete them in a single action.
+
+ Click a page to open it. This selects only that page.
+ Shift/⇧ + click to select a range of pages. This does not change the page you are viewing.
+ Ctrl/⌘ + click to add or remove one page from the selection. This does not navigate.
+
+Right-click a selected page and choose Delete pages . Confirm to remove all selected pages in one step. A file always keeps at least one page. Page separators cannot be included in the selection.
+
Page separators
You can add visual dividers in the page list to group pages without extra structure. Create an empty page, then rename it to ---. The page appears as a horizontal line in the list.
A page only becomes a separator if its name is --- and the page has no content on the canvas. A page with content that is named --- stays a normal page. Separators cannot be opened or selected; you can reorder or delete them like other pages.
diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk
index 76b323ec31..b06057a9b9 100644
--- a/docs/user-guide/first-steps/shortcuts.njk
+++ b/docs/user-guide/first-steps/shortcuts.njk
@@ -681,8 +681,8 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
Curve
- Ctrl C
- ⌘ C
+ Shift C
+ ⇧ C
Ellipse
@@ -694,6 +694,11 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
Shift K
⇧ K
+
+ Line
+ L
+ L
+
Path
P
diff --git a/docs/user-guide/first-steps/the-interface.njk b/docs/user-guide/first-steps/the-interface.njk
index 570087d6e8..c670c0f6c6 100644
--- a/docs/user-guide/first-steps/the-interface.njk
+++ b/docs/user-guide/first-steps/the-interface.njk
@@ -41,9 +41,9 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
Viewport: An infinite canvas where you can design without limits.
- Toolbar: This is where you’ll find all the tools to quickly and easily create different types of layers: board, rectangle, ellipse, text, graphic, path, and free drawing. Learn more about layers.
+ Toolbar: Tools to create layers. Board, text, and image stay on the toolbar. Shapes (rectangle, ellipse, line, and arrow) and free-draw tools (path and curve) are grouped in flyouts. Open a flyout to pick a tool, or use its shortcut. Learn more about layers.
Main menu: From the main menu, you can customize your workspace. Manage the visibility of grids, rulers, and panels. Enable or disable snapping and dynamic alignment. Add or remove the file as a Shared Library. You’ll also find help resources here.
- Pages: A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages as needed.
+ Pages: A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages, and select several pages at once to delete them. More about pages.
Layers: Layers are the different objects you can place in the design viewport. More about Layers panel.
Rulers: Rulers provide coordinates to help you design. You can also drag guides from them.
Color palette: The color palette gives you quick access to a visible library of colors. Use the menu to easily switch between libraries. Learn more about the color palette. .
@@ -137,7 +137,7 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
User area: This must be you! Access your profile settings , Penpot tutorials, the Penpot Community and more. You can also find here a way to leave us feedback. We’d love to read your thoughts :).
Comments notifications: Here you will be able to see if you have unread comments inside the files of the team. There's also a button to mark all notifications as read.
Create project: Create as many projects as you need to organize your designs.
- File card: Basic information about a file at plain sight. A preview, update info or if it’s added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete).
+ File card: Basic information about a file at plain sight. A preview, update info or if it’s added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete). You can also switch the files area between a thumbnail grid and a compact list. More about grid and list view.
Libraries & Templates module: A curated selection of Libraries & Templates files ready to import.
diff --git a/exporter/package.json b/exporter/package.json
index d17104e0b5..61ee7849a9 100644
--- a/exporter/package.json
+++ b/exporter/package.json
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml
index 02f1974302..660f44b821 100644
--- a/exporter/pnpm-lock.yaml
+++ b/exporter/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/exporter/pnpm-workspace.yaml b/exporter/pnpm-workspace.yaml
index ac94ff8d53..3a65caa2ac 100644
--- a/exporter/pnpm-workspace.yaml
+++ b/exporter/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
allowBuilds:
core-js-pure: false
minimumReleaseAgeExclude:
diff --git a/exporter/src/app/auth.cljs b/exporter/src/app/auth.cljs
index d6ac0cf042..0bcfb27199 100644
--- a/exporter/src/app/auth.cljs
+++ b/exporter/src/app/auth.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.auth
"Resolves the caller's session cookie to a real profile id.
diff --git a/exporter/src/app/handlers/export.cljs b/exporter/src/app/handlers/export.cljs
index a7b309f652..65f5a7bcd6 100644
--- a/exporter/src/app/handlers/export.cljs
+++ b/exporter/src/app/handlers/export.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.handlers.export
"Handle export jobs"
diff --git a/exporter/src/app/handlers/jobs.cljs b/exporter/src/app/handlers/jobs.cljs
index eca18e8cca..08c687a3d7 100644
--- a/exporter/src/app/handlers/jobs.cljs
+++ b/exporter/src/app/handlers/jobs.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.handlers.jobs
"REST surface for export jobs, under `/api/export/jobs`.
diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs
index 2630c847c7..e3497b2861 100644
--- a/exporter/src/app/jobs.cljs
+++ b/exporter/src/app/jobs.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs
"Export job model and lifecycle.
diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs
index efad44d99f..320689d90d 100644
--- a/exporter/src/app/jobs/scheduler.cljs
+++ b/exporter/src/app/jobs/scheduler.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.scheduler
"Admission control for export jobs.
diff --git a/exporter/src/app/jobs/store.cljs b/exporter/src/app/jobs/store.cljs
index 502c2d0676..3cfb27b951 100644
--- a/exporter/src/app/jobs/store.cljs
+++ b/exporter/src/app/jobs/store.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.store
"Redis persistence for export jobs.
diff --git a/exporter/src/app/jobs/utils.cljs b/exporter/src/app/jobs/utils.cljs
index 37480bb706..1aca381616 100644
--- a/exporter/src/app/jobs/utils.cljs
+++ b/exporter/src/app/jobs/utils.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.utils
"Temp file ownership for export jobs.
diff --git a/exporter/src/app/router.cljs b/exporter/src/app/router.cljs
index 991ab60a24..7b763edc8a 100644
--- a/exporter/src/app/router.cljs
+++ b/exporter/src/app/router.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.router
"Method + path dispatch.
diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs
index cd6cbc09ea..0160a89a4a 100644
--- a/exporter/src/app/wasm.cljs
+++ b/exporter/src/app/wasm.cljs
@@ -25,6 +25,7 @@
[app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
+ [cuerdas.core :as str]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
@@ -190,6 +191,22 @@
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
+(defn store-image-url!
+ "Registers the public URL an image was loaded from. The SVG export emits
+ linked `` from these, and falls back to Skia base64 when missing,
+ so this should run for every media id the scene references (including
+ already-cached images).
+
+ Does NOT call `mem/free`, for the same reason as `store-font-url!`."
+ [image-id url]
+ (when (and (some? url) (not (str/blank? url)))
+ (let [bytes (js/Buffer.from url "utf-8")
+ ptr (mem/alloc (.-byteLength bytes))
+ quart (uuid/get-u32 image-id)]
+ (mem/write-buffer ptr (mem/get-heap-u8) bytes)
+ (h/call wasm/internal-module "_store_image_url"
+ (aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
+
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
diff --git a/exporter/src/app/wasm/pool.cljs b/exporter/src/app/wasm/pool.cljs
index 64f8c9d05e..e9e8ded681 100644
--- a/exporter/src/app/wasm/pool.cljs
+++ b/exporter/src/app/wasm/pool.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.pool
"Pool of headless render workers.
diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs
index 6993c53bba..77e53a9e6b 100644
--- a/exporter/src/app/wasm/render.cljs
+++ b/exporter/src/app/wasm/render.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.render
"Headless render pipeline: renders exports with the render-wasm Skia pipeline,
@@ -369,13 +369,18 @@
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
- and repeated exports of a file reuse them."
+ and repeated exports of a file reuse them.
+
+ Always registers a public media URL for each id so SVG export can emit linked
+ `` even when the encoded bytes were already cached."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
+ (doseq [image-id all-ids]
+ (wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
diff --git a/exporter/src/app/wasm/worker.cljs b/exporter/src/app/wasm/worker.cljs
index c58f0f9cb2..5e5aeec5fc 100644
--- a/exporter/src/app/wasm/worker.cljs
+++ b/exporter/src/app/wasm/worker.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.worker
"Render worker entry point.
diff --git a/exporter/test/exporter_tests/export_shapes_test.cljs b/exporter/test/exporter_tests/export_shapes_test.cljs
index d512cabd78..23a682a295 100644
--- a/exporter/test/exporter_tests/export_shapes_test.cljs
+++ b/exporter/test/exporter_tests/export_shapes_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.export-shapes-test
"Chunking of the browser backend."
diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs
index 899c0f350f..c8baa9b9fd 100644
--- a/exporter/test/exporter_tests/jobs_test.cljs
+++ b/exporter/test/exporter_tests/jobs_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.jobs-test
"Job state machine. Runs without redis: a store write with no connection is
diff --git a/exporter/test/exporter_tests/scheduler_test.cljs b/exporter/test/exporter_tests/scheduler_test.cljs
index 48c3791632..04349480bf 100644
--- a/exporter/test/exporter_tests/scheduler_test.cljs
+++ b/exporter/test/exporter_tests/scheduler_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.scheduler-test
"Admission control. A headless job leases one render worker for its whole run,
diff --git a/exporter/test/exporter_tests/wasm_pool_test.cljs b/exporter/test/exporter_tests/wasm_pool_test.cljs
index 4bf85eaa82..80837626e2 100644
--- a/exporter/test/exporter_tests/wasm_pool_test.cljs
+++ b/exporter/test/exporter_tests/wasm_pool_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.wasm-pool-test
"Worker leasing, against a stub pool: `with-worker` must give the worker back
diff --git a/frontend/.storybook/vitest.setup.ts b/frontend/.storybook/vitest.setup.ts
deleted file mode 100644
index f914b38bef..0000000000
--- a/frontend/.storybook/vitest.setup.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { setProjectAnnotations } from '@storybook/react-vite';
-import * as projectAnnotations from './preview';
-
-// This is an important step to apply the right configuration when testing your stories.
-// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
-setProjectAnnotations([projectAnnotations]);
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index c319bcab55..e39dbf1443 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"browserslist": [
"defaults"
],
diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json
index 682dfeb8d0..e9f6c0ffd7 100644
--- a/frontend/packages/draft-js/package.json
+++ b/frontend/packages/draft-js/package.json
@@ -4,7 +4,7 @@
"description": "Penpot Draft-JS Wrapper",
"main": "index.js",
"type": "module",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json
index ece42204ef..509d95b0ed 100644
--- a/frontend/packages/mousetrap/package.json
+++ b/frontend/packages/mousetrap/package.json
@@ -4,7 +4,7 @@
"description": "Simple library for handling keyboard shortcuts",
"main": "index.js",
"type": "module",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"author": "Craig Campbell",
"license": "Apache-2.0 WITH LLVM-exception"
}
diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json
index 4689f02e86..cc8330b50e 100644
--- a/frontend/packages/tokenscript/package.json
+++ b/frontend/packages/tokenscript/package.json
@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"type": "module",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json
index 131a0e6d85..bd2351e197 100644
--- a/frontend/packages/ui/package.json
+++ b/frontend/packages/ui/package.json
@@ -3,6 +3,7 @@
"version": "0.0.1",
"types": "./dist/index.d.ts",
"type": "module",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"exports": {
".": {
"import": "./dist/index.js"
diff --git a/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json b/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json
new file mode 100644
index 0000000000..dde4a18d20
--- /dev/null
+++ b/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json
@@ -0,0 +1,208 @@
+{
+ "~:features": {
+ "~#set": [
+ "fdata/path-data",
+ "plugins/runtime",
+ "design-tokens/v1",
+ "variants/v1",
+ "layout/grid",
+ "styles/v2",
+ "fdata/objects-map",
+ "text-editor/v2",
+ "render-wasm/v1",
+ "text-editor-wasm/v1",
+ "components/v2",
+ "fdata/shape-data-type"
+ ]
+ },
+ "~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a",
+ "~:permissions": {
+ "~:type": "~:membership",
+ "~:is-owner": true,
+ "~:is-admin": true,
+ "~:can-edit": true,
+ "~:can-read": true,
+ "~:is-logged": true
+ },
+ "~:has-media-trimmed": false,
+ "~:comment-thread-seqn": 0,
+ "~:name": "New File 3",
+ "~:revn": 1,
+ "~:modified-at": "~m1788780032767",
+ "~:vern": 0,
+ "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674",
+ "~:is-shared": false,
+ "~:migrations": {
+ "~#ordered-set": [
+ "legacy-2",
+ "legacy-3",
+ "legacy-5",
+ "legacy-6",
+ "legacy-7",
+ "legacy-8",
+ "legacy-9",
+ "legacy-10",
+ "legacy-11",
+ "legacy-12",
+ "legacy-13",
+ "legacy-14",
+ "legacy-16",
+ "legacy-17",
+ "legacy-18",
+ "legacy-19",
+ "legacy-25",
+ "legacy-26",
+ "legacy-27",
+ "legacy-28",
+ "legacy-29",
+ "legacy-31",
+ "legacy-32",
+ "legacy-33",
+ "legacy-34",
+ "legacy-36",
+ "legacy-37",
+ "legacy-38",
+ "legacy-39",
+ "legacy-40",
+ "legacy-41",
+ "legacy-42",
+ "legacy-43",
+ "legacy-44",
+ "legacy-45",
+ "legacy-46",
+ "legacy-47",
+ "legacy-48",
+ "legacy-49",
+ "legacy-50",
+ "legacy-51",
+ "legacy-52",
+ "legacy-53",
+ "legacy-54",
+ "legacy-55",
+ "legacy-56",
+ "legacy-57",
+ "legacy-59",
+ "legacy-62",
+ "legacy-65",
+ "legacy-66",
+ "legacy-67",
+ "0001-remove-tokens-from-groups",
+ "0002-normalize-bool-content-v2",
+ "0002-clean-shape-interactions",
+ "0003-fix-root-shape",
+ "0003-convert-path-content-v2",
+ "0005-deprecate-image-type",
+ "0006-fix-old-texts-fills",
+ "0008-fix-library-colors-v4",
+ "0009-clean-library-colors",
+ "0009-add-partial-text-touched-flags",
+ "0010-fix-swap-slots-pointing-non-existent-shapes",
+ "0011-fix-invalid-text-touched-flags",
+ "0012-fix-position-data",
+ "0013-fix-component-path",
+ "0013-clear-invalid-strokes-and-fills",
+ "0014-fix-tokens-lib-duplicate-ids",
+ "0014-clear-components-nil-objects",
+ "0015-fix-text-attrs-blank-strings",
+ "0015-clean-shadow-color",
+ "0016-copy-fills-from-position-data-to-text-node",
+ "0017-fix-layout-flex-dir",
+ "0018-remove-unneeded-objects-from-components",
+ "0019-fix-missing-swap-slots",
+ "0020-sync-component-id-with-near-main",
+ "0021-fix-shape-svg-attrs",
+ "0022-normalize-component-root-and-resync",
+ "0023-repair-token-themes-with-inexistent-sets",
+ "0024b-fix-stroke-cap-placement",
+ "0025-repair-empty-text-content",
+ "0026-fix-svg-raw-shapes-uuids"
+ ]
+ },
+ "~:version": 67,
+ "~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0",
+ "~:created-at": "~m1788779992563",
+ "~:backend": "db",
+ "~:data": {
+ "~:pages": [
+ "~u77d38721-22c1-81f4-8008-9a2a3e7ce675"
+ ],
+ "~:pages-index": {
+ "~u77d38721-22c1-81f4-8008-9a2a3e7ce675": {
+ "~:objects": {
+ "~#penpot/objects-map/v2": {
+ "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",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,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\"]]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57981d1d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-15\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",300.0000008940697,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",300.0000008940697,\"~:y1\",0,\"~:x2\",320.0000011920929,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a58059cfd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-35\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",700.0000020861626,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",700.0000020861626,\"~:y1\",0,\"~:x2\",720.0000023841858,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a576c177d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-9\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",180.0000005364418,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",180.0000005364418,\"~:y1\",0,\"~:x2\",200.00000083446503,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57d786dd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-26\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",520.0000015497208,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",520.0000015497208,\"~:y1\",0,\"~:x2\",540.000001847744,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57b171fc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-19\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",380.00000113248825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",380.00000113248825,\"~:y1\",0,\"~:x2\",400.0000014305115,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a578122dc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-12\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",240.00000071525574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",240.00000071525574,\"~:y1\",0,\"~:x2\",260.00000101327896,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a58193cbf": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-39\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",780.0000023245811,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",780.0000023245811,\"~:y1\",0,\"~:x2\",800.0000026226044,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5814337e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-38\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",760.0000022649765,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",760.0000022649765,\"~:y1\",0,\"~:x2\",780.0000025629997,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57e7221e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-29\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",580.0000017285347,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",580.0000017285347,\"~:y1\",0,\"~:x2\",600.0000020265579,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a580f6e3e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-37\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",740.0000022053719,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",740.0000022053719,\"~:y1\",0,\"~:x2\",760.0000025033951,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a577afc79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-11\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",220.0000006556511,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",220.0000006556511,\"~:y1\",0,\"~:x2\",240.00000095367432,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57ec0a79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-30\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",600.0000017881393,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",600.0000017881393,\"~:y1\",0,\"~:x2\",620.0000020861626,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57f5d458": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-32\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",640.0000019073486,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",640.0000019073486,\"~:y1\",0,\"~:x2\",660.0000022053719,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5833c5bb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-44\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",880.0000026226044,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",880.0000026226044,\"~:y1\",0,\"~:x2\",900.0000029206276,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5772831b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-10\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",200.00000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",200.00000059604645,\"~:y1\",0,\"~:x2\",220.00000089406967,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5849a83a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-48\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",960.000002861023,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",960.000002861023,\"~:y1\",0,\"~:x2\",980.0000031590462,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57c1477a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-22\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",440.0000013113022,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",440.0000013113022,\"~:y1\",0,\"~:x2\",460.0000016093254,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a581f6595": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-40\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",800.0000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",800.0000023841858,\"~:y1\",0,\"~:x2\",820.000002682209,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5722f855": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-0\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",20.000000298023224,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5853f075": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-50\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1000.0000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1000.0000029802322,\"~:y1\",0,\"~:x2\",1020.0000032782555,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a58713434": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5878a273\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a58713434\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",259.99999046325684,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a579e7ad4": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-16\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",320.0000009536743,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",320.0000009536743,\"~:y1\",0,\"~:x2\",340.00000125169754,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a586a1217": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-on-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",39.99999809265137,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",39.99999809265137,\"~:y1\",40.000003814697266,\"~:x2\",219.99999332427979,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a58713434\"]]]",
+ "~u099a17ba-4c81-804d-8008-9a2a588ce9f6": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58944011\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",179.99999523162842,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^=\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:strokes\",[],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^:\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a587ff716": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5885fde3\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a587ff716\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:strokes\",[],\"~:x\",259.9999752044678,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",259.9999752044678,\"~:y1\",0,\"~:x2\",519.9999656677246,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5894fa56": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-on-text-child\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",819.999960899353,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",819.999960899353,\"~:y1\",40.000003814697266,\"~:x2\",999.9999561309814,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a589b7051\"]]]",
+ "~u099a17ba-4c81-804d-8008-9a2a589b7051": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58a8f6a4\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"110\",\"~:font-weight\",\"400\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.25]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"],[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\",\"^N\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"blurred-text-child\",\"~:width\",117,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",790,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",324]],[\"^R\",[\"^ \",\"~:x\",790,\"~:y\",324]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a589b7051\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:position-data\",[[\"^ \",\"~:y\",197.22000122070312,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",142.44000244140625,\"^N\",\"Aa\"],[\"^ \",\"~:y\",329.2200012207031,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^Z\",\"ltr\",\"^M\",\"sourcesanspro\",\"^[\",142.44000244140625,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:x\",790,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",790,\"~:y\",60,\"^P\",117,\"^[\",264,\"~:x1\",790,\"~:y1\",60,\"~:x2\",907,\"~:y2\",324]],\"~:flip-x\",null,\"^[\",264,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a58248d30": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-41\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",820.0000024437904,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",820.0000024437904,\"~:y1\",0,\"~:x2\",840.0000027418137,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5800e610": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-34\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",680.0000020265579,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",680.0000020265579,\"~:y1\",0,\"~:x2\",700.0000023245811,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a585a7293": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-51\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1020.0000030398369,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1020.0000030398369,\"~:y1\",0,\"~:x2\",1040.00000333786,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a573259d2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-1\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",20.000000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",20.000000059604645,\"~:y1\",0,\"~:x2\",40.00000035762787,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57b6beb2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-20\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",400.0000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",400.0000011920929,\"~:y1\",0,\"~:x2\",420.0000014901161,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5886910d": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-on-child-exact\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\"]]]",
+ "~u099a17ba-4c81-804d-8008-9a2a584428ad": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-47\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",940.0000028014183,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",940.0000028014183,\"~:y1\",0,\"~:x2\",960.0000030994415,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5749400d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-4\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",80.00000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",80.00000023841858,\"~:y1\",0,\"~:x2\",100.0000005364418,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a578dcf0d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-14\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",280.000000834465,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",280.000000834465,\"~:y1\",0,\"~:x2\",300.00000113248825,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5864694c": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",53,\"~:name\",\"backdrop\",\"~:width\",1060.0000033974648,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",1060.0000033974648,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",320.0000047683716,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\"]]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5750f0ef": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-5\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",100.00000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",100.00000029802322,\"~:y1\",0,\"~:x2\",120.00000059604645,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a573b052e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-2\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",40.00000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",40.00000011920929,\"~:y1\",0,\"~:x2\",60.00000041723251,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a582f24ee": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-43\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",860.0000025629997,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",860.0000025629997,\"~:y1\",0,\"~:x2\",880.000002861023,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57cd8cae": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-24\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",480.0000014305115,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",480.0000014305115,\"~:y1\",0,\"~:x2\",500.0000017285347,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5765bd49": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-8\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",160.00000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",160.00000047683716,\"~:y1\",0,\"~:x2\",180.00000077486038,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a587927c9": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-off-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",true,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",299.99998569488525,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",299.99998569488525,\"~:y1\",40.000003814697266,\"~:x2\",479.9999809265137,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a587ff716\"]]]",
+ "~u099a17ba-4c81-804d-8008-9a2a5829b348": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-42\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",840.0000025033951,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",840.0000025033951,\"~:y1\",0,\"~:x2\",860.0000028014183,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a583a7c6b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-45\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",900.000002682209,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",900.000002682209,\"~:y1\",0,\"~:x2\",920.0000029802322,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a580a33cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-36\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",720.0000021457672,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",720.0000021457672,\"~:y1\",0,\"~:x2\",740.0000024437904,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a574272cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-3\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",60.000000178813934,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",60.000000178813934,\"~:y1\",0,\"~:x2\",80.00000047683716,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57bc32eb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-21\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",420.00000125169754,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",420.00000125169754,\"~:y1\",0,\"~:x2\",440.00000154972076,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a575f6f84": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-7\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",140.0000004172325,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",140.0000004172325,\"~:y1\",0,\"~:x2\",160.00000071525574,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57dcb9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-27\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",540.0000016093254,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",540.0000016093254,\"~:y1\",0,\"~:x2\",560.0000019073486,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57f0d9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-31\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",620.000001847744,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",620.000001847744,\"~:y1\",0,\"~:x2\",640.0000021457672,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57e26c66": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-28\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",560.00000166893,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",560.00000166893,\"~:y1\",0,\"~:x2\",580.0000019669533,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57a5a180": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-17\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",340.00000101327896,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",340.00000101327896,\"~:y1\",0,\"~:x2\",360.0000013113022,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57d2c500": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-25\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",500.0000014901161,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",500.0000014901161,\"~:y1\",0,\"~:x2\",520.0000017881393,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a583f7ca0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-46\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",920.0000027418137,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",920.0000027418137,\"~:y1\",0,\"~:x2\",940.0000030398369,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a578826e0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-13\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",260.0000007748604,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",260.0000007748604,\"~:y1\",0,\"~:x2\",280.0000010728836,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57abfe20": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-18\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",360.0000010728836,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",360.0000010728836,\"~:y1\",0,\"~:x2\",380.00000137090683,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a584e9da3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-49\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",980.0000029206276,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",980.0000029206276,\"~:y1\",0,\"~:x2\",1000.0000032186508,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a585f48c3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-52\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1040.0000030994415,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1040.0000030994415,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57faee43": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-33\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",660.0000019669533,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",660.0000019669533,\"~:y1\",0,\"~:x2\",680.0000022649765,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57590dc2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-6\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.00000035762787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",120.00000035762787,\"~:y1\",0,\"~:x2\",140.0000006556511,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]",
+ "~u099a17ba-4c81-804d-8008-9a2a57c81962": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-23\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",460.00000137090683,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",460.00000137090683,\"~:y1\",0,\"~:x2\",480.00000166893005,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]"
+ }
+ },
+ "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce675",
+ "~:name": "bg-blur-clip"
+ }
+ },
+ "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674",
+ "~:options": {
+ "~:components-v2": true,
+ "~:base-font-size": "16px"
+ }
+ }
+}
diff --git a/frontend/playwright/data/render-wasm/get-file-text-span-decoration.json b/frontend/playwright/data/render-wasm/get-file-text-span-decoration.json
new file mode 100644
index 0000000000..b3e4ebe3c1
--- /dev/null
+++ b/frontend/playwright/data/render-wasm/get-file-text-span-decoration.json
@@ -0,0 +1,2339 @@
+{
+ "~:features": {
+ "~#set": [
+ "fdata/path-data",
+ "plugins/runtime",
+ "design-tokens/v1",
+ "layout/grid",
+ "styles/v2",
+ "fdata/pointer-map",
+ "fdata/objects-map",
+ "render-wasm/v1",
+ "components/v2",
+ "fdata/shape-data-type"
+ ]
+ },
+ "~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660",
+ "~:permissions": {
+ "~:type": "~:membership",
+ "~:is-owner": true,
+ "~:is-admin": true,
+ "~:can-edit": true,
+ "~:can-read": true,
+ "~:is-logged": true
+ },
+ "~:has-media-trimmed": false,
+ "~:comment-thread-seqn": 0,
+ "~:name": "text-span-decoration",
+ "~:revn": 7,
+ "~:modified-at": "~m1749629891313",
+ "~:vern": 0,
+ "~:id": "~u1d0f6a4c-0000-8000-8006-000000000001",
+ "~:is-shared": false,
+ "~:migrations": {
+ "~#ordered-set": [
+ "legacy-2",
+ "legacy-3",
+ "legacy-5",
+ "legacy-6",
+ "legacy-7",
+ "legacy-8",
+ "legacy-9",
+ "legacy-10",
+ "legacy-11",
+ "legacy-12",
+ "legacy-13",
+ "legacy-14",
+ "legacy-16",
+ "legacy-17",
+ "legacy-18",
+ "legacy-19",
+ "legacy-25",
+ "legacy-26",
+ "legacy-27",
+ "legacy-28",
+ "legacy-29",
+ "legacy-31",
+ "legacy-32",
+ "legacy-33",
+ "legacy-34",
+ "legacy-36",
+ "legacy-37",
+ "legacy-38",
+ "legacy-39",
+ "legacy-40",
+ "legacy-41",
+ "legacy-42",
+ "legacy-43",
+ "legacy-44",
+ "legacy-45",
+ "legacy-46",
+ "legacy-47",
+ "legacy-48",
+ "legacy-49",
+ "legacy-50",
+ "legacy-51",
+ "legacy-52",
+ "legacy-53",
+ "legacy-54",
+ "legacy-55",
+ "legacy-56",
+ "legacy-57",
+ "legacy-59",
+ "legacy-62",
+ "legacy-65",
+ "legacy-66",
+ "legacy-67",
+ "0001-remove-tokens-from-groups",
+ "0002-normalize-bool-content",
+ "0002-clean-shape-interactions",
+ "0003-fix-root-shape",
+ "0003-convert-path-content",
+ "0004-clean-shadow-and-colors",
+ "0005-deprecate-image-type",
+ "0006-fix-old-texts-fills",
+ "0007-clear-invalid-strokes-and-fills-v2",
+ "0008-fix-library-colors-opacity"
+ ]
+ },
+ "~:version": 67,
+ "~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07",
+ "~:created-at": "~m1749629823499",
+ "~:data": {
+ "~:pages": [
+ "~u1d0f6a4c-0000-8000-8006-000000000002"
+ ],
+ "~:pages-index": {
+ "~u1d0f6a4c-0000-8000-8006-000000000002": {
+ "~:objects": {
+ "~u00000000-0000-0000-0000-000000000000": {
+ "~#shape": {
+ "~:y": 0,
+ "~:hide-fill-on-export": false,
+ "~:transform": {
+ "~#matrix": {
+ "~:a": 1.0,
+ "~:b": 0.0,
+ "~:c": 0.0,
+ "~:d": 1.0,
+ "~:e": 0.0,
+ "~:f": 0.0
+ }
+ },
+ "~:rotation": 0,
+ "~:name": "Root Frame",
+ "~:width": 0.01,
+ "~:type": "~:frame",
+ "~:points": [
+ {
+ "~#point": {
+ "~:x": 0.0,
+ "~:y": 0.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 0.01,
+ "~:y": 0.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 0.01,
+ "~:y": 0.01
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 0.0,
+ "~:y": 0.01
+ }
+ }
+ ],
+ "~:r2": 0,
+ "~:proportion-lock": false,
+ "~:transform-inverse": {
+ "~#matrix": {
+ "~:a": 1.0,
+ "~:b": 0.0,
+ "~:c": 0.0,
+ "~:d": 1.0,
+ "~:e": 0.0,
+ "~:f": 0.0
+ }
+ },
+ "~:r3": 0,
+ "~:r1": 0,
+ "~:id": "~u00000000-0000-0000-0000-000000000000",
+ "~:parent-id": "~u00000000-0000-0000-0000-000000000000",
+ "~:frame-id": "~u00000000-0000-0000-0000-000000000000",
+ "~:strokes": [],
+ "~:x": 0,
+ "~:proportion": 1.0,
+ "~:r4": 0,
+ "~:selrect": {
+ "~#rect": {
+ "~:x": 0,
+ "~:y": 0,
+ "~:width": 0.01,
+ "~:height": 0.01,
+ "~:x1": 0,
+ "~:y1": 0,
+ "~:x2": 0.01,
+ "~:y2": 0.01
+ }
+ },
+ "~:fills": [
+ {
+ "~:fill-color": "#FFFFFF",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:flip-x": null,
+ "~:height": 0.01,
+ "~:flip-y": null,
+ "~:shapes": [
+ "~u1d0f6a4c-0000-8000-8006-000000000003",
+ "~u1d0f6a4c-0000-8000-8006-000000000004"
+ ]
+ }
+ },
+ "~u1d0f6a4c-0000-8000-8006-000000000003": {
+ "~:y": 100.0,
+ "~:transform": {
+ "~#matrix": {
+ "~:a": 1.0,
+ "~:b": 0.0,
+ "~:c": 0.0,
+ "~:d": 1.0,
+ "~:e": 0.0,
+ "~:f": 0.0
+ }
+ },
+ "~:rotation": 0,
+ "~:grow-type": "~:fixed",
+ "~:content": {
+ "~:type": "root",
+ "~:key": "span-decoration-root",
+ "~:children": [
+ {
+ "~:type": "paragraph-set",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "plain "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "under"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " mid "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "struck"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " tail"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-same-fill",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "plain "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#1A7FDA",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "under"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " mid "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#DA1A1A",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "struck"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " tail"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-mixed-fill",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "a "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "one"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " b "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "two"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " c"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-two-underlines",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "x "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "one"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " y "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "cut"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " z "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u5",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "two"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-underline-strike-underline",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "When three bodies orbit "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "each"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " other, the "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "resulting"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w5",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "dynamical"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w6",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " system is "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w7",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "chaotic"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w8",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " for most initial "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w9",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "conditions"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-wrapped",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "m "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "one"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " n "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "cut"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " o"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-two-fills",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ }
+ ]
+ }
+ ],
+ "~:vertical-align": "top"
+ },
+ "~:hide-in-viewer": false,
+ "~:name": "span decorations",
+ "~:width": 800.0,
+ "~:type": "~:text",
+ "~:points": [
+ {
+ "~#point": {
+ "~:x": 100.0,
+ "~:y": 100.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 900.0,
+ "~:y": 100.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 900.0,
+ "~:y": 700.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 100.0,
+ "~:y": 700.0
+ }
+ }
+ ],
+ "~:layout-item-h-sizing": "~:fix",
+ "~:transform-inverse": {
+ "~#matrix": {
+ "~:a": 1.0,
+ "~:b": 0.0,
+ "~:c": 0.0,
+ "~:d": 1.0,
+ "~:e": 0.0,
+ "~:f": 0.0
+ }
+ },
+ "~:layout-item-v-sizing": "~:fix",
+ "~:id": "~u1d0f6a4c-0000-8000-8006-000000000003",
+ "~:parent-id": "~u00000000-0000-0000-0000-000000000000",
+ "~:frame-id": "~u00000000-0000-0000-0000-000000000000",
+ "~:x": 100.0,
+ "~:selrect": {
+ "~#rect": {
+ "~:x": 100.0,
+ "~:y": 100.0,
+ "~:width": 800.0,
+ "~:height": 600.0,
+ "~:x1": 100.0,
+ "~:y1": 100.0,
+ "~:x2": 900.0,
+ "~:y2": 700.0
+ }
+ },
+ "~:flip-x": null,
+ "~:height": 600.0,
+ "~:flip-y": null
+ },
+ "~u1d0f6a4c-0000-8000-8006-000000000004": {
+ "~:y": 760.0,
+ "~:transform": {
+ "~#matrix": {
+ "~:a": 1.0,
+ "~:b": 0.0,
+ "~:c": 0.0,
+ "~:d": 1.0,
+ "~:e": 0.0,
+ "~:f": 0.0
+ }
+ },
+ "~:rotation": 0,
+ "~:grow-type": "~:fixed",
+ "~:content": {
+ "~:type": "root",
+ "~:key": "span-decoration-root",
+ "~:children": [
+ {
+ "~:type": "paragraph-set",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "plain "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "under"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " mid "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "struck"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "sf4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " tail"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-same-fill",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "plain "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#1A7FDA",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "under"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " mid "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#DA1A1A",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "struck"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "mf4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " tail"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-mixed-fill",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "a "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "one"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " b "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "two"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "t4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " c"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-two-underlines",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "x "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "one"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " y "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "cut"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " z "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "u5",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "two"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-underline-strike-underline",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "When three bodies orbit "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "each"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " other, the "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "resulting"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w5",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "dynamical"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w6",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " system is "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w7",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "chaotic"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w8",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " for most initial "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "w9",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "conditions"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-wrapped",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:children": [
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v0",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "m "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v1",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "underline",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "one"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v2",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " n "
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v3",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "line-through",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": "cut"
+ },
+ {
+ "~:line-height": "1.2",
+ "~:font-style": "normal",
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:font-id": "sourcesanspro",
+ "~:key": "v4",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#2E7D32",
+ "~:fill-opacity": 0.5
+ },
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro",
+ "~:text": " o"
+ }
+ ],
+ "~:typography-ref-id": null,
+ "~:text-transform": "none",
+ "~:text-align": "left",
+ "~:font-id": "sourcesanspro",
+ "~:key": "p-two-fills",
+ "~:font-size": "48",
+ "~:font-weight": "400",
+ "~:typography-ref-file": null,
+ "~:text-direction": "ltr",
+ "~:type": "paragraph",
+ "~:font-variant-id": "regular",
+ "~:text-decoration": "none",
+ "~:letter-spacing": "0",
+ "~:fills": [
+ {
+ "~:fill-color": "#000000",
+ "~:fill-opacity": 1
+ }
+ ],
+ "~:font-family": "sourcesanspro"
+ }
+ ]
+ }
+ ],
+ "~:vertical-align": "top"
+ },
+ "~:hide-in-viewer": false,
+ "~:name": "span decorations with shadow",
+ "~:width": 800.0,
+ "~:type": "~:text",
+ "~:points": [
+ {
+ "~#point": {
+ "~:x": 100.0,
+ "~:y": 760.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 900.0,
+ "~:y": 760.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 900.0,
+ "~:y": 1360.0
+ }
+ },
+ {
+ "~#point": {
+ "~:x": 100.0,
+ "~:y": 1360.0
+ }
+ }
+ ],
+ "~:layout-item-h-sizing": "~:fix",
+ "~:transform-inverse": {
+ "~#matrix": {
+ "~:a": 1.0,
+ "~:b": 0.0,
+ "~:c": 0.0,
+ "~:d": 1.0,
+ "~:e": 0.0,
+ "~:f": 0.0
+ }
+ },
+ "~:layout-item-v-sizing": "~:fix",
+ "~:id": "~u1d0f6a4c-0000-8000-8006-000000000004",
+ "~:parent-id": "~u00000000-0000-0000-0000-000000000000",
+ "~:frame-id": "~u00000000-0000-0000-0000-000000000000",
+ "~:x": 100.0,
+ "~:selrect": {
+ "~#rect": {
+ "~:x": 100.0,
+ "~:y": 760.0,
+ "~:width": 800.0,
+ "~:height": 600.0,
+ "~:x1": 100.0,
+ "~:y1": 760.0,
+ "~:x2": 900.0,
+ "~:y2": 1360.0
+ }
+ },
+ "~:flip-x": null,
+ "~:height": 600.0,
+ "~:flip-y": null,
+ "~:shadow": [
+ {
+ "~:id": "~u1d0f6a4c-0000-8000-8006-0000000000f1",
+ "~:style": "~:drop-shadow",
+ "~:color": {
+ "~:color": "#DA1A1A",
+ "~:opacity": 1
+ },
+ "~:offset-x": 8,
+ "~:offset-y": 8,
+ "~:blur": 0,
+ "~:spread": 0,
+ "~:hidden": false
+ }
+ ]
+ }
+ },
+ "~:id": "~u1d0f6a4c-0000-8000-8006-000000000002",
+ "~:name": "Page 1"
+ }
+ },
+ "~:id": "~u1d0f6a4c-0000-8000-8006-000000000001",
+ "~:options": {
+ "~:components-v2": true,
+ "~:base-font-size": "16px"
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js
index a85b70887e..909f494d5d 100644
--- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js
+++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js
@@ -608,4 +608,21 @@ test("Renders background blur under strokes on rects, paths and texts", async ({
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
-});
\ No newline at end of file
+});
+
+test("Renders background blur clipped by a board with clip content", async ({
+ page,
+}) => {
+ const workspace = new WasmWorkspacePage(page);
+ await workspace.setupEmptyFile();
+ await workspace.mockGetFile("render-wasm/get-file-background-blur-clip.json");
+
+ await workspace.goToWorkspace({
+ id: "77d38721-22c1-81f4-8008-9a2a3e7ce674",
+ pageId: "77d38721-22c1-81f4-8008-9a2a3e7ce675",
+ pageName: "bg-blur-clip",
+ });
+ await workspace.waitForFirstRenderWithoutUI();
+
+ await expect(workspace.canvas).toHaveScreenshot();
+});
diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png
new file mode 100644
index 0000000000..6f5057acfb
Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js
index 4122d7e307..b4d4c8373b 100644
--- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js
+++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js
@@ -274,6 +274,25 @@ test("Renders a file with different text leaves decoration", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
+// Both paragraphs decorate the same spans; the first one paints every span with
+// the same fill, which used to collapse the decorated spans into their
+// neighbours and drop their underline / line-through.
+test("Renders text spans decorated independently of their fill", async ({
+ page,
+}) => {
+ const workspace = new WasmWorkspacePage(page);
+ await workspace.setupEmptyFile();
+ await workspace.mockGetFile("render-wasm/get-file-text-span-decoration.json");
+
+ await workspace.goToWorkspace({
+ id: "1d0f6a4c-0000-8000-8006-000000000001",
+ pageId: "1d0f6a4c-0000-8000-8006-000000000002",
+ });
+
+ await workspace.waitForFirstRenderWithoutUI();
+ await expect(workspace.canvas).toHaveScreenshot();
+});
+
test("Renders a file with different text shadows combinations", async ({
page,
}) => {
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png
index 101315c965..794396f670 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-leaves-decoration-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png
index 906a1e5b76..a0ab6eb273 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-different-text-shadows-combinations-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png
index 70ba1f8cbf..673065e4c6 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png
index 646da5cdb5..076a83fb09 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-group-with-strokes-and-not-100-opacities-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png
index 9ef0018566..76ea3820c4 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-multiple-text-shadows-strokes-and-blur-combinations-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png
index 3d9abd0bae..b3d708819e 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-styled-texts-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png
index 5725d511bb..821478ccb7 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-text-decoration-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png
index 4ce228ad4d..691fe62d70 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-different-alignments-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png
index f0ae6555ba..40e7f3bdc1 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-emoji-and-different-symbols-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png
index ded0cc7465..e7121ff76e 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-images-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png
index 2a83a06ac5..6243bdc685 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-paragraphs-and-breaking-lines-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png
index a5f78ca8f7..09a4b78740 100644
Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-texts-with-with-text-spans-of-different-sizes-1.png differ
diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-text-spans-decorated-independently-of-their-fill-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-text-spans-decorated-independently-of-their-fill-1.png
new file mode 100644
index 0000000000..098a61acd1
Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-text-spans-decorated-independently-of-their-fill-1.png differ
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 01866c4118..64aa97b44e 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
index 2c3a2c026f..c30ce8c1ea 100644
--- a/frontend/pnpm-workspace.yaml
+++ b/frontend/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
packages:
- "packages/draft-js"
- "packages/mousetrap"
diff --git a/frontend/src/app/main/constants.cljs b/frontend/src/app/main/constants.cljs
index 4aeaa0461e..1c401c9558 100644
--- a/frontend/src/app/main/constants.cljs
+++ b/frontend/src/app/main/constants.cljs
@@ -299,7 +299,21 @@
:height 1152}
{:name "YouTube thumb"
:width 1280
- :height 720}])
+ :height 720}
+
+ {:name "PENPOT"}
+ {:name "File thumbnail"
+ :width 300
+ :height 200}
+ {:name "Template cover"
+ :width 1390
+ :height 781}
+ {:name "Plugin icon"
+ :width 400
+ :height 400}
+ {:name "Plugin cover"
+ :width 1390
+ :height 724}])
(def max-input-length 255)
diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs
index c156536de5..2b28a07acb 100644
--- a/frontend/src/app/main/data/comments.cljs
+++ b/frontend/src/app/main/data/comments.cljs
@@ -19,6 +19,7 @@
[app.main.data.team :as dtm]
[app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]]
+ [app.util.storage :as storage]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -531,6 +532,35 @@
(update [_ state]
(update state :comments-local dissoc :expanded))))
+(def ^:private hide-resolved-comments-storage-key
+ :app.main.data.comments/hide-resolved-comments?)
+
+(defn- load-hide-resolved-comments?
+ []
+ (= true (get @storage/user hide-resolved-comments-storage-key)))
+
+(defn- persist-hide-resolved-comments!
+ [hide?]
+ (swap! storage/user assoc hide-resolved-comments-storage-key hide?))
+
+(defn merge-persisted-filters
+ "Merge persisted hide-resolved preference into comments local state."
+ [local]
+ (let [local (or local {})]
+ (if (contains? local :show)
+ local
+ (assoc local :show (if (load-hide-resolved-comments?)
+ :pending
+ :all)))))
+
+(defn initialize-comments-filters
+ "Load persisted comment filter preferences into `:comments-local`."
+ []
+ (ptk/reify ::initialize-comments-filters
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :comments-local merge-persisted-filters))))
+
(defn update-filters
[{:keys [mode show list] :as params}]
(ptk/reify ::update-filters
@@ -546,7 +576,12 @@
(assoc :show show)
(some? list)
- (assoc :list list)))))))
+ (assoc :list list)))))
+
+ ptk/EffectEvent
+ (effect [_ _ _]
+ (when (some? show)
+ (persist-hide-resolved-comments! (= :pending show))))))
(defn update-options
[params]
diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs
index 4ad40beade..7e6de36796 100644
--- a/frontend/src/app/main/data/viewer.cljs
+++ b/frontend/src/app/main/data/viewer.cljs
@@ -77,7 +77,8 @@
(if (nil? lstate)
default-local-state
lstate)))
- (assoc-in [:viewer-local :share-id] share-id)))
+ (assoc-in [:viewer-local :share-id] share-id)
+ (update :comments-local dcmt/merge-persisted-filters)))
ptk/WatchEvent
(watch [_ state _]
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index 245df6008f..aa8ddcd7c8 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -403,7 +403,8 @@
(assoc :recent-fonts (:recent-fonts storage/user))
(assoc :current-file-id file-id)
(assoc :workspace-presence {})
- (update :workspace-global dissoc :default-font)))
+ (update :workspace-global dissoc :default-font)
+ (update :comments-local dcmt/merge-persisted-filters)))
ptk/WatchEvent
(watch [_ state stream]
diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs
index d4e7f587f8..433d9fea6f 100644
--- a/frontend/src/app/main/data/workspace/colors.cljs
+++ b/frontend/src/app/main/data/workspace/colors.cljs
@@ -333,13 +333,16 @@
[:stroke-style
:stroke-alignment
:stroke-width
+ :stroke-dash
+ :stroke-gap
:stroke-per-side
:stroke-width-top
:stroke-width-right
:stroke-width-bottom
:stroke-width-left
:stroke-cap-start
- :stroke-cap-end])
+ :stroke-cap-end
+ :hidden])
;; FIXME: this function initializes an empty stroke, maybe we can move
;; it to common.types
diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs
index b78add32b7..47d97b4249 100644
--- a/frontend/src/app/main/data/workspace/modifiers.cljs
+++ b/frontend/src/app/main/data/workspace/modifiers.cljs
@@ -99,7 +99,9 @@
:layout-item-margin-type
:layout-grid-cells
:layout-grid-columns
- :layout-grid-rows})
+ :layout-grid-rows
+ :fills
+ :fill-image})
;; -- temporary modifiers -------------------------------------------
@@ -703,9 +705,9 @@
#_:clj-kondo/ignore
(defn set-wasm-modifiers
- [modif-tree & {:keys [ignore-constraints ignore-snap-pixel
+ [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis
subtree-ids-by-id selection-rect-cache]
- :or {ignore-constraints false ignore-snap-pixel false}
+ :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil}
:as params}]
(let [modif-tree (without-nil-ids modif-tree)]
(ptk/reify ::set-wasm-modifiers
@@ -756,7 +758,7 @@
root-modifiers
:else
- (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
+ (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)]
(if (seq propagated) propagated root-modifiers)))]
(when wasm-ready?
(wasm.api/set-modifiers modifiers))
@@ -831,10 +833,8 @@
;; primaries and descendants would snap back to their
;; pre-drag positions on drop.
;;
- ;; Skipped when `snap-pixel?` is on: WASM applies
- ;; per-shape pixel correction (different scale/translate
- ;; per descendant) which we can't replicate cheaply on
- ;; the CLJS side.
+ ;; Only without `snap-pixel?`: the delta that lands
+ ;; the shape on the pixel grid is known to WASM alone.
(reduce
(fn [acc [id data]]
(let [t (:transform data)
@@ -864,7 +864,7 @@
geometry-entries))
:else
- (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
+ (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
diff --git a/frontend/src/app/main/data/workspace/path/clipboard.cljs b/frontend/src/app/main/data/workspace/path/clipboard.cljs
index 003b555e6b..5d1e0f5bab 100644
--- a/frontend/src/app/main/data/workspace/path/clipboard.cljs
+++ b/frontend/src/app/main/data/workspace/path/clipboard.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.workspace.path.clipboard
(:require
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index 1908884677..1056c085e0 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -124,7 +124,8 @@
(defn- await-font-faces
"Waits for missing WASM faces, then resizes the affected texts."
[stream face-keys ids]
- (let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))]
+ (let [resize-opts {:stack-undo? true :undo-transation? false}
+ resize-stream (->> (rx/from ids) (rx/map #(dwwt/resize-wasm-text % resize-opts)))]
(if (empty? face-keys)
resize-stream
(->> (rx/merge wasm.fonts/font-stored-stream
diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs
index ebc6e79d9e..b83d09842a 100644
--- a/frontend/src/app/main/data/workspace/thumbnails.cljs
+++ b/frontend/src/app/main/data/workspace/thumbnails.cljs
@@ -12,6 +12,7 @@
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.component :as ctc]
+ [app.common.types.shape-tree :as ctt]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
@@ -23,6 +24,7 @@
[app.main.render :as render]
[app.main.repo :as rp]
[app.util.queue :as q]
+ [app.util.storage :as storage]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
@@ -289,13 +291,60 @@
(mapcat get-frame-ids-cached))
changes))))
+;; Board thumbnails used to render text shapes without position-data as
+;; nothing instead of falling back to the foreignObject renderer (see
+;; frame-imposter in app.main.render), so any board thumbnail cached before
+;; that fix may be missing its text. The backend doesn't tell the client
+;; when a fetched thumbnail was generated, so we can't tell stale apart from
+;; fresh by inspecting it; instead each board thumbnail with text content is
+;; regenerated at most once per browser, tracked via local-storage so repeat
+;; visits (once healed) don't keep re-rendering it.
+(def ^:private healed-storage-key ::healed-text-thumbnails)
+
+(defn- frame-has-text?
+ [objects frame-id]
+ (->> (cfh/get-children-with-self objects frame-id)
+ (some cfh/text-shape?)
+ (some?)))
+
+(defn- unhealed-text-thumbnail?
+ [state object-id]
+ (and (some? (dm/get-in state [:thumbnails object-id :uri]))
+ (not (contains? (get @storage/global healed-storage-key) object-id))))
+
+(defn- mark-thumbnail-healed!
+ [object-id]
+ (swap! storage/global update healed-storage-key (fnil conj #{}) object-id))
+
+(defn- heal-stale-text-thumbnails
+ "Emits an `update-thumbnail` for every board on the page that has text
+ content and hasn't already been healed (see `healed-storage-key`) in this
+ browser."
+ [state file-id page-id]
+ (let [objects (-> (dsh/lookup-file-data state file-id)
+ (dsh/get-page page-id)
+ :objects)
+ frame-ids (ctt/get-root-frames-ids objects)
+ xf (comp
+ (filter #(frame-has-text? objects %))
+ (keep
+ (fn [frame-id]
+ (let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")]
+ (when (unhealed-text-thumbnail? state object-id)
+ [frame-id object-id])))))]
+ (->> (rx/from (eduction xf frame-ids))
+ (rx/map
+ (fn [[frame-id object-id]]
+ (mark-thumbnail-healed! object-id)
+ (update-thumbnail file-id page-id frame-id "frame" "heal-stale-text-thumbnails"))))))
+
(defn watch-state-changes
"Watch the state for changes inside frames. If a change is detected will force a rendering
of the frame data so the thumbnail can be updated."
[file-id page-id]
(ptk/reify ::watch-state-changes
ptk/WatchEvent
- (watch [_ _ stream]
+ (watch [_ state stream]
(let [stopper-s (rx/filter
(fn [event]
(as-> (ptk/type event) type
@@ -330,6 +379,10 @@
(rx/tap #(l/trc :hint "buffer initialized")))]
(->> (rx/merge
+ ;; Heal boards with text whose cached thumbnail may predate the
+ ;; text-position fix (see heal-stale-text-thumbnails).
+ (heal-stale-text-thumbnails state file-id page-id)
+
;; Perform instant thumbnail cleaning of affected frames
;; and interrupt any ongoing update-thumbnail process
;; related to current frame-id
diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs
index aa43f69c1d..15990f2de3 100644
--- a/frontend/src/app/main/data/workspace/transforms.cljs
+++ b/frontend/src/app/main/data/workspace/transforms.cljs
@@ -149,10 +149,15 @@
;; -- Resize --------------------------------------------------------
+(defn- shape-has-image-fill?
+ [shape]
+ (boolean (or (some :fill-image (:fills shape))
+ (:fill-image shape))))
+
(defn start-resize
"Enter mouse resize mode, until mouse button is released."
[handler ids shape]
- (letfn [(resize [shape initial layout objects [point lock? center? point-snap]]
+ (letfn [(resize [shape initial layout objects [point lock? center? bounds-resize? point-snap]]
(let [selrect (dm/get-prop shape :selrect)
width (dm/get-prop selrect :width)
height (dm/get-prop selrect :height)
@@ -235,7 +240,59 @@
(not (mth/close? (dm/get-prop scalev :x) 1))
change-height?
- (not (mth/close? (dm/get-prop scalev :y) 1))]
+ (not (mth/close? (dm/get-prop scalev :y) 1))
+
+ ;; Calculate independent image bounds resize transform
+ sx (dm/get-prop scalev :x)
+ sy (dm/get-prop scalev :y)
+ w-new (* width sx)
+ h-new (* height sy)
+
+ bounds-resize? (and ^boolean bounds-resize?
+ (pos? w-new)
+ (pos? h-new))
+
+ [dx dy] (if ^boolean center?
+ [(/ (* width (- 1.0 sx)) 2.0)
+ (/ (* height (- 1.0 sy)) 2.0)]
+ [(case handler
+ (:left :bottom-left :top-left) (* width (- 1.0 sx))
+ 0.0)
+ (case handler
+ (:top :top-left :top-right) (* height (- 1.0 sy))
+ 0.0)])
+
+ new-fills
+ (when (and bounds-resize? (seq (:fills shape)))
+ (mapv (fn [fill]
+ (if-let [img-fill (:fill-image fill)]
+ (let [tf (get img-fill :transform)
+ nx0 (get tf :x 0.0)
+ ny0 (get tf :y 0.0)
+ nw0 (get tf :width 1.0)
+ nh0 (get tf :height 1.0)
+ nx' (/ (- (* nx0 width) dx) w-new)
+ ny' (/ (- (* ny0 height) dy) h-new)
+ nw' (/ nw0 sx)
+ nh' (/ nh0 sy)]
+ (assoc-in fill [:fill-image :transform]
+ {:x nx' :y ny' :width nw' :height nh'}))
+ fill))
+ (:fills shape)))
+
+ new-fill-image
+ (when (and bounds-resize? (some? (:fill-image shape)))
+ (let [img-fill (:fill-image shape)
+ tf (get img-fill :transform)
+ nx0 (get tf :x 0.0)
+ ny0 (get tf :y 0.0)
+ nw0 (get tf :width 1.0)
+ nh0 (get tf :height 1.0)
+ nx' (/ (- (* nx0 width) dx) w-new)
+ ny' (/ (- (* ny0 height) dy) h-new)
+ nw' (/ nw0 sx)
+ nh' (/ nh0 sy)]
+ (assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))]
(cond-> (ctm/empty)
(some? displacement)
@@ -258,18 +315,30 @@
(and new-grow-type (not= new-grow-type (dm/get-prop shape :grow-type)))
(ctm/change-property :grow-type new-grow-type)
+ (and bounds-resize? (some? new-fills))
+ (ctm/change-property :fills new-fills)
+
+ (and bounds-resize? (some? new-fill-image))
+ (ctm/change-property :fill-image new-fill-image)
+
^boolean scale-text
(ctm/scale-content (dm/get-prop scalev :x)))))
;; Unifies the instantaneous proportion lock modifier
;; activated by Shift key and the shapes own proportion
;; lock flag that can be activated on element options.
- (normalize-proportion-lock [[point shift? alt?]]
- (let [proportion-lock? (:proportion-lock shape)]
+ (normalize-proportion-lock [[point shift? alt? mod?]]
+ (let [has-img? (shape-has-image-fill? shape)
+ bounds-resize? (and has-img? (boolean mod?))
+ proportion-lock? (:proportion-lock shape)
+ lock? (if bounds-resize?
+ (boolean shift?)
+ (or ^boolean proportion-lock?
+ ^boolean shift?))]
[point
- (or ^boolean proportion-lock?
- ^boolean shift?)
- alt?]))]
+ lock?
+ alt?
+ bounds-resize?]))]
(reify
ptk/UpdateEvent
(update [_ state]
@@ -297,10 +366,10 @@
resize-events-stream
(->> ms/mouse-position
(rx/filter some?)
- (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt)
+ (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt ms/mouse-position-mod)
(rx/map normalize-proportion-lock)
(rx/switch-map
- (fn [[point _ _ :as current]]
+ (fn [[point _ _ _ :as current]]
(->> (snap/closest-snap-point page-id shapes objects layout zoom focus point)
(rx/map #(conj current %)))))
(rx/map #(resize shape initial-position layout objects %))
@@ -592,12 +661,14 @@
(rx/merge
(->> angle-stream
(rx/sample mconst/rotation-sample-time)
- (rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)))
+ (rx/map #(dwm/set-wasm-modifiers (rotation-modifiers % shapes group-center)
+ :ignore-snap-pixel true))
(rx/take-until stopper))
(->> angle-stream
(rx/take-until stopper)
(rx/last)
- (rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)))))
+ (rx/map #(dwm/apply-wasm-modifiers (rotation-modifiers % shapes group-center)
+ :ignore-snap-pixel true))))
(rx/of (finish-transform)))
@@ -638,7 +709,9 @@
modif-tree
(dwm/build-modif-tree ids objects get-modifier)]
- (rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
+ (rx/of (dwm/apply-wasm-modifiers modif-tree
+ :ignore-touched (:ignore-touched options)
+ :ignore-snap-pixel true)))
(let [page-id (or (:page-id options)
(:current-page-id state))
diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs
index 04495ad0f2..435c8082c4 100644
--- a/frontend/src/app/main/data/workspace/wasm_text.cljs
+++ b/frontend/src/app/main/data/workspace/wasm_text.cljs
@@ -79,20 +79,26 @@
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.
- No-op if the id is not a text shape or is :fixed."
- [id]
- (ptk/reify ::resize-wasm-text
- ptk/WatchEvent
- (watch [_ state _]
- (let [objects (dsh/lookup-page-objects state)
- shape (get objects id)
- resize-stream
- (if (and (some? shape)
- (cfh/text-shape? shape)
- (not= :fixed (:grow-type shape)))
- (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
- (rx/empty))]
- (wrf/with-pending :text-resize [id] resize-stream)))))
+ No-op if the id is not a text shape or is :fixed.
+ `opts` are forwarded to `apply-wasm-modifiers`, so a caller whose undo
+ transaction is already closed when the resize lands can still get the
+ geometry into the right undo entry."
+ ([id]
+ (resize-wasm-text id nil))
+ ([id opts]
+ (ptk/reify ::resize-wasm-text
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [objects (dsh/lookup-page-objects state)
+ shape (get objects id)
+ apply-opts (or opts {})
+ resize-stream
+ (if (and (some? shape)
+ (cfh/text-shape? shape)
+ (not= :fixed (:grow-type shape)))
+ (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape) apply-opts))
+ (rx/empty))]
+ (wrf/with-pending :text-resize [id] resize-stream))))))
(defn- merge-resize-debounce-opts
[prev {:keys [undo-group undo-id skip-component-sync?]}]
diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs
index 5c185f83c4..4a45630454 100644
--- a/frontend/src/app/main/render.cljs
+++ b/frontend/src/app/main/render.cljs
@@ -266,16 +266,17 @@
[{:keys [objects frame vbox x y width height background]}]
(let [shape-wrapper (shape-wrapper-factory objects)]
[:& (mf/provider muc/render-thumbnails) {:value false}
- [:svg {:view-box vbox
- :width (ust/format-precision width viewbox-decimal-precision)
- :height (ust/format-precision height viewbox-decimal-precision)
- :version "1.1"
- :xmlns "http://www.w3.org/2000/svg"
- :xmlnsXlink "http://www.w3.org/1999/xlink"
- :fill "none"}
- (when (some? background)
- [:rect {:x x :y y :width width :height height :fill background}])
- [:& shape-wrapper {:shape frame}]]]))
+ [:& (mf/provider muc/is-render?) {:value true}
+ [:svg {:view-box vbox
+ :width (ust/format-precision width viewbox-decimal-precision)
+ :height (ust/format-precision height viewbox-decimal-precision)
+ :version "1.1"
+ :xmlns "http://www.w3.org/2000/svg"
+ :xmlnsXlink "http://www.w3.org/1999/xlink"
+ :fill "none"}
+ (when (some? background)
+ [:rect {:x x :y y :width width :height height :fill background}])
+ [:& shape-wrapper {:shape frame}]]]]))
;; Component that serves for render frame thumbnails, mainly used in
;; the viewer and inspector
diff --git a/frontend/src/app/main/ui/components/editable_select.cljs b/frontend/src/app/main/ui/components/editable_select.cljs
index 470f95025c..3c9474a98f 100644
--- a/frontend/src/app/main/ui/components/editable_select.cljs
+++ b/frontend/src/app/main/ui/components/editable_select.cljs
@@ -13,7 +13,7 @@
[app.common.uuid :as uuid]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.numeric-input :as deprecated-input]
- [app.main.ui.icons :as deprecated-icon]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.timers :as timers]
@@ -182,7 +182,10 @@
[:span {:class (stl/css :dropdown-button)
:on-click toggle-dropdown}
- deprecated-icon/arrow]
+ [:> icon* {:icon-id i/arrow-down
+ :size "m"
+ :aria-hidden true
+ :class (stl/css :dropdown-icon)}]]
[:& dropdown {:show (or is-open? false)
:on-close close-dropdown}
@@ -196,9 +199,12 @@
[:li
{:key (str element-id "-" index)
:class (stl/css-case :dropdown-element true
- :is-selected (= (dm/str value) current-value))
+ :is-selected (= (dm/str value) (dm/str current-value)))
:data-value value
:on-click select-item}
[:span {:class (stl/css :label)} label]
[:span {:class (stl/css :check-icon)}
- deprecated-icon/tick]])))]]]))
+ [:> icon* {:icon-id i/tick
+ :aria-hidden true
+ :size "s"
+ :class (stl/css :check-tick)}]]])))]]]))
diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss
index 490e549c0a..7257ee14f2 100644
--- a/frontend/src/app/main/ui/components/editable_select.scss
+++ b/frontend/src/app/main/ui/components/editable_select.scss
@@ -4,87 +4,126 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
-// FIXME: we need this import for %asset-element
-@use "refactor/basic-rules.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/spacing.scss" as *;
+@use "ds/typography.scss" as *;
.editable-select {
- @extend %asset-element;
+ @include use-typography("body-small");
+
+ --editable-select-background-color: var(--color-background-tertiary);
margin: 0;
- border: $b-1 solid var(--input-border-color);
+ border: $b-1 solid var(--color-background-tertiary);
position: relative;
display: flex;
- height: $sz-32;
- width: 100%;
+ align-items: center;
+ inline-size: 100%;
+ block-size: $sz-32;
padding: var(--sp-s);
border-radius: $br-8;
cursor: pointer;
+ background-color: var(--editable-select-background-color);
+ color: var(--color-foreground-primary);
- .dropdown-button {
- display: flex;
- place-content: center;
-
- svg {
- @extend %button-icon-small;
-
- transform: rotate(90deg);
- stroke: var(--icon-foreground);
- }
- }
-
- .custom-select-dropdown {
- @extend %dropdown-wrapper;
-
- width: max-content;
- max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token
- .separator {
- margin: 0;
- height: $sz-12;
- }
-
- .dropdown-element {
- @extend %dropdown-element-base;
-
- color: var(--menu-foreground-color-rest);
-
- .label {
- flex-grow: 1;
- width: 100%;
- }
-
- .check-icon {
- display: flex;
- place-content: center;
-
- svg {
- @extend %button-icon-small;
-
- visibility: hidden;
- stroke: var(--icon-foreground);
- }
- }
-
- &.is-selected {
- color: var(--menu-foreground-color);
-
- .check-icon svg {
- stroke: var(--menu-foreground-color);
- visibility: visible;
- }
- }
-
- &:hover {
- background-color: var(--menu-background-color-hover);
- color: var(--menu-foreground-color-hover);
-
- .check-icon svg {
- stroke: var(--menu-foreground-color-hover);
- }
- }
- }
+ &:hover {
+ --editable-select-background-color: var(--color-background-quaternary);
}
}
+
+.dropdown-button {
+ display: flex;
+ place-content: center;
+}
+
+.dropdown-icon {
+ color: var(--color-foreground-secondary);
+}
+
+.custom-select-dropdown {
+ position: absolute;
+ inset-block-start: $sz-32;
+ inset-inline-start: 0;
+ inline-size: 100%;
+ min-inline-size: px2rem(70);
+ max-block-size: px2rem(320); // TODO: when this gets addressed in the DS, use a token
+ padding: var(--sp-xxs);
+ margin: 0;
+ margin-block-start: px2rem(1);
+ border: $b-2 solid var(--color-background-quaternary);
+ border-radius: $br-8;
+ z-index: var(--z-index-dropdown);
+ overflow: hidden auto;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+ box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
+}
+
+.separator {
+ margin: 0;
+ block-size: $sz-12;
+}
+
+.dropdown-element {
+ --dropdown-element-color: var(--color-foreground-secondary);
+ --dropdown-element-icon-color: var(--color-foreground-secondary);
+
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ block-size: $sz-32;
+ padding-block: 0;
+ padding-inline: var(--sp-s);
+ border-radius: $br-6;
+ cursor: pointer;
+ color: var(--dropdown-element-color);
+
+ @include use-typography("body-small");
+
+ &.is-selected {
+ --dropdown-element-color: var(--color-foreground-primary);
+ --dropdown-element-icon-color: var(--color-foreground-primary);
+
+ .check-tick {
+ visibility: visible;
+ }
+ }
+
+ &:hover {
+ --dropdown-element-color: var(--color-foreground-primary);
+ --dropdown-element-icon-color: var(--color-foreground-primary);
+
+ background-color: var(--color-background-quaternary);
+ }
+}
+
+.label,
+.check-icon {
+ display: block;
+ max-inline-size: 99%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.label {
+ flex-grow: 1;
+ inline-size: 100%;
+}
+
+.check-icon {
+ display: flex;
+ place-content: center;
+}
+
+.check-tick {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ fill: none;
+ stroke-width: 1.33px;
+ visibility: hidden;
+ color: var(--dropdown-element-icon-color);
+}
diff --git a/frontend/src/app/main/ui/shapes/fills.cljs b/frontend/src/app/main/ui/shapes/fills.cljs
index 3b7c29da0b..41238e7608 100644
--- a/frontend/src/app/main/ui/shapes/fills.cljs
+++ b/frontend/src/app/main/ui/shapes/fills.cljs
@@ -119,31 +119,43 @@
(if (:fill-image value)
(let [uri (cf/resolve-file-media (:fill-image value))
keep-ar? (-> value :fill-image :keep-aspect-ratio)
+ tf (-> value :fill-image :transform)
+ img-x (if (some? tf) (* (get tf :x 0) width) 0)
+ img-y (if (some? tf) (* (get tf :y 0) height) 0)
+ img-w (if (some? tf) (* (get tf :width 1) width) width)
+ img-h (if (some? tf) (* (get tf :height 1) height) height)
image-props #js {:id (dm/str "fill-image-" render-id "-" fill-index)
:href (get embed uri uri)
:preserveAspectRatio (if keep-ar? "xMidYMid slice" "none")
- :width width
- :height height
+ :x img-x
+ :y img-y
+ :width img-w
+ :height img-h
:key (dm/str fill-index)
:opacity (:fill-opacity value)}]
[:> :image image-props])
[:> :rect props])))
(when ^boolean has-image?
- [:g
- ;; We add this shape to add a padding so the patter won't repeat
- ;; Issue: https://tree.taiga.io/project/penpot/issue/5583
- [:rect {:x 0
- :y 0
- :width (* width no-repeat-padding)
- :height (* height no-repeat-padding)
- :fill "none"}]
- [:image {:href uri
- :preserveAspectRatio "none"
- :x 0
- :y 0
- :width width
- :height height}]])]])])))
+ (let [tf (-> image :transform)
+ img-x (if (some? tf) (* (get tf :x 0) width) 0)
+ img-y (if (some? tf) (* (get tf :y 0) height) 0)
+ img-w (if (some? tf) (* (get tf :width 1) width) width)
+ img-h (if (some? tf) (* (get tf :height 1) height) height)]
+ [:g
+ ;; We add this shape to add a padding so the patter won't repeat
+ ;; Issue: https://tree.taiga.io/project/penpot/issue/5583
+ [:rect {:x 0
+ :y 0
+ :width (* width no-repeat-padding)
+ :height (* height no-repeat-padding)
+ :fill "none"}]
+ [:image {:href uri
+ :preserveAspectRatio "none"
+ :x img-x
+ :y img-y
+ :width img-w
+ :height img-h}]]))]])])))
(mf/defc fills
{::mf/wrap-props false}
diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs
index 91ab7f82f2..aac174d880 100644
--- a/frontend/src/app/main/ui/viewer/comments.cljs
+++ b/frontend/src/app/main/ui/viewer/comments.cljs
@@ -95,6 +95,16 @@
[:span {:class (stl/css :icon)}
deprecated-icon/tick])]
+ [:li {:class (stl/css-case
+ :dropdown-element true
+ :selected (= :mentions cmode))
+ :data-value "mentions"
+ :on-click update-mode}
+ [:span {:class (stl/css :label)} (tr "labels.show-mentions")]
+ (when (= :mentions cmode)
+ [:span {:class (stl/css :icon)}
+ deprecated-icon/tick])]
+
[:li {:class (stl/css :separator)}]
[:li {:class (stl/css-case
diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss
index 7fcd840b9e..c254f0b9ba 100644
--- a/frontend/src/app/main/ui/viewer/comments.scss
+++ b/frontend/src/app/main/ui/viewer/comments.scss
@@ -4,6 +4,7 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
+@use "ds/_borders.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
// COMMENT DROPDOWN ON HEADER
@@ -92,7 +93,12 @@
}
.separator {
- height: deprecated.$s-8;
+ position: relative;
+ block-size: var(--sp-xs);
+ inline-size: calc(100% + var(--sp-s));
+ border-top: $b-1 solid var(--color-background-quaternary);
+ left: calc(-1 * var(--sp-xs));
+ margin-top: var(--sp-s);
}
// FLOATING COMMENT
diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss
index 685ac5c0df..b94930789d 100644
--- a/frontend/src/app/main/ui/workspace/comments.scss
+++ b/frontend/src/app/main/ui/workspace/comments.scss
@@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_sizes.scss" as *;
+@use "ds/_borders.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.comments-section {
@@ -112,7 +113,12 @@
}
.separator {
- height: deprecated.$s-12;
+ position: relative;
+ block-size: var(--sp-xs);
+ inline-size: calc(100% + var(--sp-s));
+ border-top: $b-1 solid var(--color-background-quaternary);
+ left: calc(-1 * var(--sp-xs));
+ margin-top: var(--sp-s);
}
.comments-section-content {
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs
index 608a25c537..ea4857a40a 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs
@@ -12,16 +12,18 @@
[app.main.data.workspace.grid :as dw]
[app.main.refs :as refs]
[app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.editable-select :refer [editable-select]]
- [app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.components.select :refer [select]]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
- [app.main.ui.ds.foundations.assets.icon :as i]
- [app.main.ui.icons :as deprecated-icon]
+ [app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
[app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]]
+ [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
+ [app.util.keyboard :as kbd]
[okulary.core :as l]
[rumext.v2 :as mf]))
@@ -33,6 +35,49 @@
:separator
18 12 10 8 6 4 3 2])
+(mf/defc default-options-toggle*
+ "Toggle button that opens the reset/save-as-default menu for a grid's
+ params. Shared by the square, column and row grid-type layouts, each of
+ which places it at a different point in their own layout."
+ [{:keys [show disabled on-toggle]}]
+ [:button {:class (stl/css-case :show-more-options true
+ :selected show)
+ :on-click on-toggle
+ :disabled disabled}
+ [:> icon* {:icon-id i/menu
+ :size "m"
+ :aria-hidden true
+ :class (stl/css :show-options-icon)}]])
+
+(mf/defc default-options-dropdown*
+ "Dropdown menu with the reset/save-as-default options for a grid's params.
+ Shared by the square, column and row grid-type layouts, each of which
+ places it at a different point in their own layout (its `:class` controls
+ the panel's positioning, which differs per layout)."
+ [{:keys [class show on-close on-use-default on-set-as-default]}]
+ (let [handle-key-down
+ (fn [action]
+ (fn [event]
+ (when (or (kbd/enter? event) (kbd/space? event))
+ (dom/prevent-default event)
+ (action))))]
+ [:& dropdown {:show show
+ :on-close on-close}
+ [:ul {:class class
+ :role "menu"}
+ [:li {:class (stl/css :option-btn)
+ :role "menuitem"
+ :tab-index 0
+ :on-click on-use-default
+ :on-key-down (handle-key-down on-use-default)}
+ (tr "workspace.options.grid.params.use-default")]
+ [:li {:class (stl/css :option-btn)
+ :role "menuitem"
+ :tab-index 0
+ :on-click on-set-as-default
+ :on-key-down (handle-key-down on-set-as-default)}
+ (tr "workspace.options.grid.params.set-default")]]]))
+
(mf/defc grid-options*
{::mf/wrap [mf/memo]}
[{:keys [shape-id index grid frame-width frame-height default-grid-params]}]
@@ -151,7 +196,10 @@
[:button {:class (stl/css-case :show-options true
:selected open?)
:on-click toggle-advanced-options}
- deprecated-icon/menu]
+ [:> icon* {:icon-id i/menu
+ :size "m"
+ :aria-hidden true
+ :class (stl/css :show-options-icon)}]]
[:div {:class (stl/css :type-select-wrapper)}
[:& select
{:class (stl/css :grid-type-select)
@@ -163,11 +211,10 @@
(if (= type :square)
[:div {:class (stl/css :grid-size)
:title (tr "workspace.options.size")}
- [:> deprecated-input/numeric-input* {:min 0.01
- :value (or (:size params) "")
- :no-validate true
- :class (stl/css :numeric-input)
- :on-change (handle-change :params :size)}]]
+ [:> numeric-input* {:min 0.01
+ :value (or (:size params) "")
+ :inner-class (stl/css :numeric-input)
+ :on-change (handle-change :params :size)}]]
[:div {:class (stl/css :editable-select-wrapper)}
[:& editable-select {:value (:size params)
@@ -204,22 +251,14 @@
:origin :guides
:on-change handle-change-color
:on-detach handle-detach-color}]
- [:button {:class (stl/css-case :show-more-options true
- :selected show-more-options?)
- :on-click toggle-more-options}
- deprecated-icon/menu]]
- (when show-more-options?
- [:div {:class (stl/css :second-row)}
- [:button {:class (stl/css-case :btn-options true
- :disabled is-default)
- :disabled is-default
- :on-click handle-use-default}
- [:span (tr "workspace.options.grid.params.use-default")]]
- [:button {:class (stl/css-case :btn-options true
- :disabled is-default)
- :disabled is-default
- :on-click handle-set-as-default}
- [:span (tr "workspace.options.grid.params.set-default")]]])])
+ [:> default-options-toggle* {:show show-more-options?
+ :disabled is-default
+ :on-toggle toggle-more-options}]]
+ [:> default-options-dropdown* {:class (stl/css :second-row)
+ :show show-more-options?
+ :on-close close-more-options
+ :on-use-default handle-use-default
+ :on-set-as-default handle-set-as-default}]])
(when (or (= :column type) (= :row type))
[:div {:class (stl/css :column-row)}
@@ -252,49 +291,39 @@
:title (if (= :row type)
(tr "workspace.options.grid.params.height")
(tr "workspace.options.grid.params.width"))}
- [:span {:class (stl/css :icon-text)}
- (if (= :row type)
- "H"
- "W")]
- [:> deprecated-input/numeric-input* {:placeholder "Auto"
- :on-change handle-change-item-length
- :is-nillable true
- :class (stl/css :numeric-input)
- :value (or (:item-length params) "")}]]
+ [:> numeric-input* {:placeholder "Auto"
+ :on-change handle-change-item-length
+ :nillable true
+ :icon (if (= :row type) i/character-h i/character-w)
+ :inner-class (stl/css :numeric-input)
+ :value (or (:item-length params) "")}]]
[:div {:class (stl/css :gutter)
:title (tr "workspace.options.grid.params.gutter")}
- [:span {:class (stl/css-case :icon true
- :rotated (= type :row))}
- deprecated-icon/gap-horizontal]
- [:> deprecated-input/numeric-input* {:placeholder "0"
- :on-change (handle-change :params :gutter)
- :is-nillable true
- :class (stl/css :numeric-input)
- :value (or (:gutter params) 0)}]]
+ [:> numeric-input* {:placeholder "0"
+ :on-change (handle-change :params :gutter)
+ :nillable true
+ :icon (if (= type :row) i/gap-vertical i/gap-horizontal)
+ :inner-class (stl/css :numeric-input)
+ :value (or (:gutter params) 0)}]]
[:div {:class (stl/css :margin)
:title (tr "workspace.options.grid.params.margin")}
- [:span {:class (stl/css-case :icon true
- :rotated (= type :column))}
- deprecated-icon/grid-margin]
- [:> deprecated-input/numeric-input* {:placeholder "0"
- :on-change (handle-change :params :margin)
- :is-nillable true
- :class (stl/css :numeric-input)
- :value (or (:margin params) 0)}]]
+ [:> numeric-input* {:placeholder "0"
+ :on-change (handle-change :params :margin)
+ :nillable true
+ :icon (if (= type :column) i/margin-left-right i/margin-top-bottom)
+ :inner-class (stl/css :numeric-input)
+ :value (or (:margin params) 0)}]]
- [:button {:class (stl/css-case :show-more-options true
- :selected show-more-options?)
- :on-click toggle-more-options
- :disabled is-default}
- deprecated-icon/menu]
- (when show-more-options?
- [:div {:class (stl/css :more-options)}
- [:button {:class (stl/css :option-btn)
- :on-click handle-use-default} (tr "workspace.options.grid.params.use-default")]
- [:button {:class (stl/css :option-btn)
- :on-click handle-set-as-default} (tr "workspace.options.grid.params.set-default")]])]])])]))
+ [:> default-options-toggle* {:show show-more-options?
+ :disabled is-default
+ :on-toggle toggle-more-options}]
+ [:> default-options-dropdown* {:class (stl/css :more-options)
+ :show show-more-options?
+ :on-close close-more-options
+ :on-use-default handle-use-default
+ :on-set-as-default handle-set-as-default}]]])])]))
(defn- check-frame-grid-props
[old-props new-props]
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss
index 8f97f4dc42..ba80378e2a 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss
@@ -4,9 +4,24 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
-@use "refactor/common-refactor.scss" as deprecated;
+@use "ds/_borders.scss" as *;
+@use "ds/_sizes.scss" as *;
+@use "ds/_utils.scss" as *;
+@use "ds/z-index.scss" as *;
+@use "ds/spacing.scss" as *;
+@use "ds/typography.scss" as *;
@use "../../../sidebar/common/sidebar.scss" as sidebar;
+// Shared by every disabled-looking control in the .hidden state below.
+@mixin hidden-control {
+ cursor: default;
+ pointer-events: none;
+ box-sizing: border-box;
+ color: var(--color-foreground-secondary);
+ stroke: var(--color-foreground-secondary);
+ background-color: transparent;
+}
+
.element-set {
@include sidebar.option-grid-structure;
}
@@ -16,15 +31,17 @@
}
.title-spacing-board-grid {
- padding-left: deprecated.$s-2;
+ padding-inline-start: var(--sp-xxs);
margin: 0;
}
.element-set-content {
- @include deprecated.flex-column;
-
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
grid-column: span 8;
- margin: deprecated.$s-4 0 deprecated.$s-8 0;
+ margin-block: var(--sp-xs) var(--sp-s);
+ margin-inline: 0;
}
.grid-title {
@@ -35,266 +52,388 @@
grid-column: span 6;
display: flex;
align-items: center;
- gap: deprecated.$s-1;
- border-radius: deprecated.$br-8;
- background-color: var(--input-details-color);
+ gap: px2rem(1);
+ border-radius: $br-8;
+ background-color: var(--color-background-primary);
+}
- .show-options {
- @extend %button-secondary;
+.show-options {
+ --show-options-background-color: var(--color-background-tertiary);
+ --show-options-border-color: var(--color-background-tertiary);
+ --show-options-color: var(--color-foreground-secondary);
- height: deprecated.$s-32;
- width: deprecated.$s-28;
- border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
- box-sizing: border-box;
- border: deprecated.$s-1 solid var(--input-border-color);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ block-size: $sz-32;
+ inline-size: $sz-28;
+ border-radius: $br-8 0 0 $br-8;
+ box-sizing: border-box;
+ border: $b-1 solid var(--show-options-border-color);
+ background-color: var(--show-options-background-color);
+ color: var(--show-options-color);
- svg {
- @extend %button-icon;
- }
+ &:focus-visible {
+ outline: none;
- &.selected {
- @extend %button-icon-selected;
- }
+ --show-options-border-color: var(--color-accent-primary);
}
- .type-select-wrapper {
- flex-grow: 1;
- width: deprecated.$s-96;
- padding: 0;
- border-radius: 0;
- height: deprecated.$s-32;
+ &:hover {
+ --show-options-background-color: var(--color-background-quaternary);
+ --show-options-border-color: var(--color-background-quaternary);
+ --show-options-color: var(--color-accent-primary);
- .grid-type-select {
- border-radius: 0;
- height: 100%;
- box-sizing: border-box;
- border: deprecated.$s-1 solid var(--input-border-color);
-
- &:hover {
- border: deprecated.$s-1 solid var(--input-border-color-hover);
- }
- }
+ text-decoration: none;
}
- .grid-size {
- @extend %asset-element;
+ &:active {
+ outline: none;
- width: deprecated.$s-60;
- margin: 0;
- padding: 0;
- padding-left: deprecated.$s-8;
- border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
-
- .numeric-input {
- @extend %input-base;
- @include deprecated.body-small-typography;
- }
+ --show-options-background-color: var(--color-background-secondary);
+ --show-options-border-color: var(--color-background-quaternary);
+ --show-options-color: var(--color-accent-primary);
}
- .editable-select-wrapper {
- @extend %asset-element;
+ &[disabled],
+ &:disabled {
+ --show-options-background-color: var(--color-background-quaternary);
+ --show-options-border-color: var(--color-background-quaternary);
+ --show-options-color: var(--color-foreground-disabled);
- width: deprecated.$s-60;
- margin: 0;
- padding: 0;
- position: relative;
- border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
-
- .column-select {
- height: deprecated.$s-32;
- border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
- box-sizing: border-box;
- border: deprecated.$s-1 solid var(--input-border-color);
-
- .numeric-input {
- @extend %input-base;
- @include deprecated.body-small-typography;
-
- margin: 0;
- padding: 0;
- }
-
- span {
- @include deprecated.flex-center;
-
- svg {
- @extend %button-icon;
- }
- }
- }
+ cursor: unset;
}
- &.hidden {
- .show-options {
- @include deprecated.hidden-element;
+ &.selected {
+ outline: none;
- border: deprecated.$s-1 solid var(--input-border-color-disabled);
- }
-
- .type-select-wrapper,
- .editable-select-wrapper {
- @include deprecated.hidden-element;
-
- .column-select,
- .grid-type-select {
- @include deprecated.hidden-element;
-
- border: deprecated.$s-1 solid var(--input-border-color-disabled);
- }
-
- .column-select {
- @include deprecated.hidden-element;
-
- border-radius: 0 deprecated.$br-8 deprecated.$br-8 0;
-
- .numeric-input {
- @include deprecated.hidden-element;
- }
- }
- }
-
- .grid-size {
- @include deprecated.hidden-element;
-
- border: deprecated.$s-1 solid var(--input-border-color-disabled);
-
- .icon {
- stroke: var(--input-foreground-color-disabled);
- }
-
- .numeric-input {
- color: var(--input-foreground-color-disabled);
- }
- }
-
- .actions {
- .hidden-btn,
- .lock-btn {
- background-color: transparent;
-
- svg {
- stroke: var(--input-foreground-color-disabled);
- }
- }
- }
+ --show-options-background-color: var(--color-background-quaternary);
+ --show-options-border-color: var(--color-background-quaternary);
+ --show-options-color: var(--color-accent-primary);
}
}
-.actions {
- @include deprecated.flex-row;
+.type-select-wrapper {
+ flex-grow: 1;
+ inline-size: $sz-96;
+ padding: 0;
+ border-radius: 0;
+ block-size: $sz-32;
+}
+.type-select-wrapper .grid-type-select {
+ --grid-type-select-border-color: var(--color-background-tertiary);
+
+ border-radius: 0;
+ block-size: 100%;
+ box-sizing: border-box;
+ border: $b-1 solid var(--grid-type-select-border-color);
+
+ &:hover {
+ --grid-type-select-border-color: var(--color-background-quaternary);
+ }
+}
+
+.grid-size {
+ @include use-typography("body-small");
+
+ display: flex;
+ align-items: center;
+ block-size: $sz-32;
+ inline-size: px2rem(60);
+ margin: 0;
+ padding: 0;
+ padding-inline-start: var(--sp-s);
+ border-radius: 0 $br-8 $br-8 0;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+
+ &:hover {
+ background-color: var(--color-background-quaternary);
+ color: var(--color-foreground-primary);
+ }
+}
+
+.grid-size .numeric-input {
+ --input-height: #{$sz-28};
+
+ flex-grow: 1;
+}
+
+.editable-select-wrapper {
+ inline-size: px2rem(60);
+ margin: 0;
+ padding: 0;
+ position: relative;
+}
+
+.editable-select-wrapper .column-select {
+ block-size: $sz-32;
+ border-radius: 0 $br-8 $br-8 0;
+ box-sizing: border-box;
+ border: $b-1 solid var(--color-background-tertiary);
+}
+
+.column-select .numeric-input {
+ @include use-typography("body-small");
+
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: none;
+ outline: none;
+ display: block;
+ max-inline-size: 99%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ block-size: $sz-28;
+ inline-size: 100%;
+ flex-grow: 1;
+ border-radius: $br-8;
+ color: var(--color-foreground-primary);
+
+ &[disabled] {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+}
+
+.hidden .show-options {
+ @include hidden-control;
+
+ border: $b-1 solid var(--color-background-quaternary);
+}
+
+.hidden .type-select-wrapper,
+.hidden .editable-select-wrapper {
+ @include hidden-control;
+}
+
+.hidden .column-select,
+.hidden .grid-type-select {
+ @include hidden-control;
+
+ border: $b-1 solid var(--color-background-quaternary);
+}
+
+.hidden .column-select {
+ border-radius: 0 $br-8 $br-8 0;
+}
+
+.hidden .column-select .numeric-input {
+ @include hidden-control;
+}
+
+.hidden .grid-size {
+ @include hidden-control;
+
+ border: $b-1 solid var(--color-background-quaternary);
+}
+
+.hidden .grid-size .numeric-input {
+ --input-fg-color: var(--color-foreground-secondary);
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
grid-column: span 2;
}
.grid-advanced-options {
- @include deprecated.flex-column;
-
- margin-top: deprecated.$s-4;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ margin-block-start: var(--sp-xs);
}
.column-row,
.square-row {
- @include deprecated.flex-column;
-
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
position: relative;
}
.advanced-row {
position: relative;
display: flex;
- gap: deprecated.$s-4;
+ gap: var(--sp-xs);
+}
- .orientation-select-wrapper {
- width: deprecated.$s-92;
- padding: 0;
+.orientation-select-wrapper {
+ inline-size: px2rem(92);
+ padding: 0;
+}
+
+.color-wrapper {
+ inline-size: px2rem(156);
+}
+
+.show-more-options {
+ --show-more-options-background-color: transparent;
+ --show-more-options-border-color: transparent;
+ --show-more-options-color: var(--color-foreground-secondary);
+
+ background: none;
+ cursor: pointer;
+ display: grid;
+ place-content: center;
+ block-size: $sz-32;
+ inline-size: $sz-32;
+ border-radius: $br-8;
+ background-color: var(--show-more-options-background-color);
+ border: $b-2 solid var(--show-more-options-border-color);
+ color: var(--show-more-options-color);
+
+ &:focus-visible {
+ outline: none;
+ border: $b-1 solid var(--color-accent-primary);
+
+ --show-more-options-background-color: var(--color-background-tertiary);
+ --show-more-options-color: var(--color-foreground-primary);
}
- .color-wrapper {
- width: deprecated.$s-156;
+ &:hover {
+ --show-more-options-background-color: var(--color-background-quaternary);
+ --show-more-options-border-color: var(--color-background-quaternary);
+ --show-more-options-color: var(--color-accent-primary);
}
- .show-more-options {
- @extend %button-tertiary;
+ &:active {
+ outline: none;
- height: deprecated.$s-32;
- width: deprecated.$s-32;
-
- svg {
- @extend %button-icon;
- }
-
- &.selected {
- @extend %button-icon-selected;
- }
+ --show-more-options-background-color: var(--color-background-secondary);
+ --show-more-options-border-color: transparent;
+ --show-more-options-color: var(--color-accent-primary);
}
- .height {
- @extend %input-element;
- @include deprecated.body-small-typography;
+ &[disabled],
+ &:disabled {
+ --show-more-options-color: var(--color-foreground-disabled);
- .icon-text {
- padding-top: deprecated.$s-1;
- }
+ cursor: unset;
+ pointer-events: none;
}
- .gutter,
- .margin {
- @extend %input-element;
- @include deprecated.body-small-typography;
+ &.selected {
+ outline: none;
- .icon {
- &.rotated svg {
- transform: rotate(90deg);
- }
- }
+ --show-more-options-background-color: var(--color-background-quaternary);
+ --show-more-options-border-color: var(--color-background-quaternary);
+ --show-more-options-color: var(--color-accent-primary);
+ }
+}
+
+.show-more-options svg {
+ stroke: var(--show-more-options-color);
+}
+
+.height,
+.gutter,
+.margin {
+ --grid-param-input-color: var(--color-foreground-secondary);
+ --grid-param-input-background-color: var(--color-background-tertiary);
+ --grid-param-input-border-color: var(--color-background-tertiary);
+
+ @include use-typography("body-small");
+
+ display: flex;
+ align-items: center;
+ block-size: $sz-32;
+ border-radius: $br-8;
+ background-color: var(--grid-param-input-background-color);
+ border: $b-1 solid var(--grid-param-input-border-color);
+ color: var(--grid-param-input-color);
+
+ &:hover {
+ --grid-param-input-color: var(--color-foreground-primary);
+ --grid-param-input-background-color: var(--color-background-quaternary);
+ --grid-param-input-border-color: var(--color-background-quaternary);
}
- .more-options {
- @include deprecated.menu-shadow;
- @include deprecated.flex-column;
+ &:active,
+ &:focus,
+ &:focus-within {
+ --grid-param-input-color: var(--color-foreground-primary);
+ --grid-param-input-background-color: var(--color-background-primary);
+ --grid-param-input-border-color: var(--color-accent-primary);
+ }
- position: absolute;
- top: calc(deprecated.$s-2 + deprecated.$s-28);
- right: 0;
- width: deprecated.$s-156;
- max-height: deprecated.$s-300;
- padding: deprecated.$s-2;
- margin: 0 0 deprecated.$s-40 0;
- margin-top: deprecated.$s-4;
- border-radius: deprecated.$br-8;
- z-index: deprecated.$z-index-4;
- overflow-y: auto;
- background-color: var(--menu-background-color);
+ &:focus,
+ &:focus-within {
+ --grid-param-input-background-color: var(--color-background-tertiary);
+ }
+}
- .option-btn {
- @include deprecated.button-style;
+.height .numeric-input,
+.gutter .numeric-input,
+.margin .numeric-input {
+ --input-height: #{$sz-28};
- display: flex;
- align-items: center;
- height: deprecated.$s-32;
- padding: 0 deprecated.$s-8;
- border-radius: deprecated.$br-6;
- color: var(--menu-foreground-color);
+ flex-grow: 1;
+ margin-block: var(--sp-xxs);
+ margin-inline: 0;
+ padding-inline-start: $sz-6;
+}
- &:hover {
- background-color: var(--menu-background-color-hover);
- color: var(--menu-foreground-color-hover);
- }
- }
+.more-options {
+ box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ position: absolute;
+ inset-block-start: calc(var(--sp-xxs) + $sz-28);
+ inset-inline-end: 0;
+ inline-size: px2rem(156);
+ max-block-size: px2rem(300); // TODO: when this gets addressed in the DS, use a token
+ padding: var(--sp-xxs);
+ margin-block: var(--sp-xs) $sz-40;
+ margin-inline: 0;
+ border-radius: $br-8;
+ z-index: var(--z-index-dropdown);
+ overflow-y: auto;
+ background-color: var(--color-background-tertiary);
+}
+
+.option-btn {
+ @include use-typography("body-small");
+
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ block-size: $sz-32;
+ padding-inline: var(--sp-s);
+ border-radius: $br-6;
+ color: var(--color-foreground-primary);
+
+ &:hover,
+ &:focus-visible {
+ background-color: var(--color-background-quaternary);
+ color: var(--color-foreground-primary);
+ }
+
+ &:focus-visible {
+ outline: none;
}
}
.second-row {
- @extend %dropdown-wrapper;
-
- left: unset;
- right: 0;
- width: deprecated.$s-108;
-
- .btn-options {
- @include deprecated.button-style;
- @extend %dropdown-element-base;
-
- width: 100%;
- }
+ box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
+ position: absolute;
+ inset-block-start: $sz-32;
+ inset-inline-start: unset;
+ inset-inline-end: 0;
+ inline-size: px2rem(108);
+ max-block-size: var(--menu-max-height, px2rem(300));
+ padding: var(--sp-xxs);
+ margin: 0;
+ margin-block-start: px2rem(1);
+ border-radius: $br-8;
+ z-index: var(--z-index-dropdown);
+ overflow: hidden auto;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
index 6c4268cfce..06ece36af4 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
@@ -130,6 +130,20 @@
(if-let [cached (get @optical-offset-cache key)]
(p/resolved cached)
(-> (fonts/ensure-loaded! font-id)
+ (p/then
+ (fn [_]
+ ;; ensure-loaded! only guarantees the @font-face CSS text has
+ ;; been injected, not that the browser has actually fetched and
+ ;; parsed the font file: that fetch is lazy, normally triggered
+ ;; by the browser laying out DOM text with the font. Canvas
+ ;; measureText doesn't reliably trigger it, so without this
+ ;; explicit wait it can silently measure the fallback font
+ ;; instead, and the resulting bogus offset then gets cached
+ ;; forever — pushing the sample glyphs outside the clipped
+ ;; sample box instead of just centering them slightly wrong.
+ (let [spec (dm/str (or weight "400") " " (or style "normal") " 16px \"" family "\"")]
+ (-> (.load js/document.fonts spec)
+ (p/catch (constantly nil))))))
(p/then
(fn [_]
(let [em (or (optical-offset-em family weight style text) 0)]
@@ -139,36 +153,67 @@
(defn- use-optical-offset
"Lazily resolve the optical-centering offset (in `em`) for sample text in a
given font, measuring once per font/sample and caching it. Falls back to 0
- when the font isn't available or the metrics can't be measured."
+ when the font isn't available or the metrics can't be measured.
+
+ Returns `[offset ready?]`. `ready?` is true immediately when a value is
+ already cached, and false only while the very first measurement of a given
+ font/sample is still pending. Callers should keep the sample hidden until
+ `ready?`: the offset (and so the sample's rendered position) jumps once
+ that first, async measurement resolves, and painting the glyphs before
+ then makes automated screenshot/position-based tests flaky — whether the
+ test runs before or after the jump is a timing race, not a deterministic
+ outcome."
[font-id family weight style text]
- (let [offset* (mf/use-state 0)]
+ (let [key (optical-offset-key family weight style text)
+ offset* (mf/use-state #(get @optical-offset-cache key 0))
+ ready?* (mf/use-state #(contains? @optical-offset-cache key))]
(mf/use-effect
(mf/deps font-id family weight style text)
(fn []
- (let [cancelled? (volatile! false)
- key (optical-offset-key family weight style text)]
+ (let [cancelled? (volatile! false)]
(if (contains? @optical-offset-cache key)
- (reset! offset* (get @optical-offset-cache key))
+ (do
+ (reset! offset* (get @optical-offset-cache key))
+ (reset! ready?* true))
(let [task (tm/schedule-on-idle
(fn []
(-> (load-optical-offset font-id family weight style text)
(p/then
(fn [em]
(when-not @cancelled?
- (reset! offset* em)))))))]
+ (reset! offset* em)
+ (reset! ready?* true)))))))]
(fn []
(vreset! cancelled? true)
(tm/dispose! task)))))
nil))
- (deref offset*)))
+ [(deref offset*) (deref ready?*)]))
(defn- sample-container-style
"Inline style that applies the typography font to the (clipped, fixed-height)
sample container. Must be a real JS object (`#js`), not a ClojureScript map:
the `:style` value here is a runtime expression, not a literal recognized by
- the hiccup macro, so it reaches React unconverted."
- [typography]
- #js {:fontFamily (:font-family typography)
+ the hiccup macro, so it reaches React unconverted.
+
+ Falls back to `font-data` (the live fontsdb entry for the typography's
+ `:font-id`) when the typography's own `:font-family` is blank: a font that
+ was unloaded when a typography's font/variant was last changed can leave
+ that field nil on the record (the same failure mode `remove-nil-style-attrs`
+ repairs for shape text spans), and the sample would otherwise render in
+ whatever fallback font the browser picks instead of the intended one.
+
+ The family name is quoted, matching `font-item-preview*` below: setting
+ `style.fontFamily` to a raw, unquoted string parses it as CSS's
+ `` grammar, i.e. whitespace-separated ``s. A
+ family like \"Micro 5\" then tokenizes as the ident `Micro` followed by
+ the *number* `5` — not a valid ident — so the whole property is invalid
+ CSS and the browser silently drops it. A quoted `` sidesteps that
+ entirely, since it isn't tokenized as identifiers at all."
+ [typography font-data]
+ #js {:fontFamily (let [family (:font-family typography)
+ family (if (str/blank? family) (:family font-data) family)]
+ (when-not (str/blank? family)
+ (dm/str "\"" family "\"")))
:fontWeight (:font-weight typography)
:fontStyle (:font-style typography)})
@@ -176,10 +221,14 @@
"Inline style that optically centers the sample glyphs. Must be applied to
the text node itself, not to the clipped container: a transform on an
`overflow: hidden` element moves its own clip region along with it, so it
- would shift the whole box relative to the row instead of the glyphs inside it."
- [em]
- (when-not (zero? em)
- #js {:transform (dm/str "translateY(" em "em)")}))
+ would shift the whole box relative to the row instead of the glyphs inside it.
+
+ Hidden until `ready?` (see `use-optical-offset`), so the glyphs only ever
+ appear already in their final, correctly centered position instead of
+ visibly jumping there after the first paint."
+ [em ready?]
+ #js {:transform (when-not (zero? em) (dm/str "translateY(" em "em)"))
+ :visibility (when-not ready? "hidden")})
;; --- FONT SELECTOR --------------------------------------------------------
@@ -210,11 +259,17 @@
;; the row, so shift it by the measured offset once the font is known.
;; The label renders at `body-medium` (400/normal), which is the weight
;; and style we measure against.
- label-offset (use-optical-offset font-id
- (:family font)
- "400"
- "normal"
- (:name font))]
+ ;; The selector always shows the font's name text as-is, unlike the
+ ;; small "Ag" sample elsewhere in this file, so unlike there this
+ ;; doesn't need to hide anything until the offset is ready — a
+ ;; shifting label is a minor, acceptable visual nicety here, not
+ ;; the row's only content.
+ [label-offset _label-ready?]
+ (use-optical-offset font-id
+ (:family font)
+ "400"
+ "normal"
+ (:name font))]
(if in-sprite?
;; `fill: currentColor` (scss) makes the sprite glyph follow the row color.
[:svg {:class (stl/css :font-item-preview)
@@ -702,11 +757,12 @@
font-data (fonts/get-font-data (:font-id typography))
typography-id (:id typography)
show-actions? (and is-asset? is-editable)
- offset (use-optical-offset (:font-id typography)
- (:font-family typography)
- (:font-weight typography)
- (:font-style typography)
- "Ag")
+ [offset offset-ready?]
+ (use-optical-offset (:font-id typography)
+ (:font-family typography)
+ (:font-weight typography)
+ (:font-style typography)
+ "Ag")
on-delete
(mf/use-fn
@@ -741,8 +797,8 @@
[:*
[:div {:class (stl/css :font-name-wrapper)}
[:div {:class (stl/css :typography-sample-input)
- :style (sample-container-style typography)}
- [:span {:style (sample-text-style offset)}
+ :style (sample-container-style typography font-data)}
+ [:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:input
@@ -777,8 +833,8 @@
[:div {:class (stl/css :typography-info-wrapper)}
[:div {:class (stl/css :typography-name-wrapper)}
[:div {:class (stl/css :typography-sample)
- :style (sample-container-style typography)}
- [:span {:style (sample-text-style offset)}
+ :style (sample-container-style typography font-data)}
+ [:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:div {:class (stl/css :typography-name)
@@ -826,11 +882,12 @@
open? (deref open*)
font-data (fonts/get-font-data (:font-id typography))
name-only? (= (:name typography) (:name font-data))
- offset (use-optical-offset (:font-id typography)
- (:font-family typography)
- (:font-weight typography)
- (:font-style typography)
- "Ag")
+ [offset offset-ready?]
+ (use-optical-offset (:font-id typography)
+ (:font-family typography)
+ (:font-weight typography)
+ (:font-style typography)
+ "Ag")
on-name-blur
(mf/use-fn
@@ -865,6 +922,13 @@
(when ^boolean esc?
(dom/blur! input-node)))))]
+ ;; use-optical-offset only triggers a font load as a side effect of an
+ ;; uncached offset measurement, so on a cache hit (e.g. a `0` offset
+ ;; cached from before the font was ever loaded) the font itself never
+ ;; gets fetched and the sample silently renders in the fallback font.
+ ;; Load it unconditionally too, same as the advanced-options view does.
+ (fonts/ensure-loaded! (:font-id typography))
+
(mf/with-effect [is-editing]
(when is-editing
(reset! open* is-editing)))
@@ -888,8 +952,8 @@
[:div {:class (stl/css :font-name-wrapper)}
[:div
{:class (stl/css :typography-sample-input)
- :style (sample-container-style typography)}
- [:span {:style (sample-text-style offset)}
+ :style (sample-container-style typography font-data)}
+ [:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:input
@@ -907,8 +971,8 @@
:on-context-menu on-context-menu}
[:div
{:class (stl/css :typography-sample)
- :style (sample-container-style typography)}
- [:span {:style (sample-text-style offset)}
+ :style (sample-container-style typography font-data)}
+ [:span {:style (sample-text-style offset offset-ready?)}
(tr "workspace.assets.typography.sample")]]
[:div {:class (stl/css :name-block)
diff --git a/frontend/src/app/main/ui/workspace/viewport/path_state.cljs b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs
index 0f1d7da23b..7f31441904 100644
--- a/frontend/src/app/main/ui/workspace/viewport/path_state.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.workspace.viewport.path-state
(:require
diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
index 8b75af07f0..a4c378fa8d 100644
--- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
@@ -20,6 +20,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
+ [app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.refs :as refs]
@@ -314,11 +315,14 @@
:bottom :auto-height
nil)]
(when (some? grow-type)
- (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type)))
- ;; The WASM renderer needs an explicit reflow after the grow-type change
- (when (features/active-feature? @st/state "render-wasm/v1")
- (st/emit! (dwwt/resize-wasm-text-all [shape-id])
- (ptk/data-event :layout/update {:ids [shape-id]}))))))))]
+ (let [uid (js/Symbol)]
+ (st/emit! (dwu/start-undo-transaction uid)
+ (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type)))
+ ;; The WASM renderer needs an explicit reflow after the grow-type change
+ (if (features/active-feature? @st/state "render-wasm/v1")
+ (st/emit! (dwwt/resize-wasm-text-all [shape-id] {:undo-id uid})
+ (ptk/data-event :layout/update {:ids [shape-id]}))
+ (st/emit! (dwu/commit-undo-transaction uid)))))))))]
[:g.resize-handler
(when ^boolean show-handler
diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs
index 643e793787..0b265a0148 100644
--- a/frontend/src/app/plugins/api.cljs
+++ b/frontend/src/app/plugins/api.cljs
@@ -47,6 +47,7 @@
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
+ [app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.system-events :as se]
[app.plugins.user :as user]
@@ -242,15 +243,18 @@
:getCurrentUser
(fn []
- (user/current-user-proxy plugin-id (:session-id @st/state)))
+ (when (r/check-permission plugin-id "user:read")
+ (user/current-user-proxy plugin-id (:session-id @st/state))))
:getActiveUsers
(fn []
- (apply array
- (->> (:workspace-presence @st/state)
- (vals)
- (remove #(= (:id %) (:session-id @st/state)))
- (map #(user/active-user-proxy plugin-id (:id %))))))
+ (if (r/check-permission plugin-id "user:read")
+ (apply array
+ (->> (:workspace-presence @st/state)
+ (vals)
+ (remove #(= (:id %) (:session-id @st/state)))
+ (map #(user/active-user-proxy plugin-id (:id %)))))
+ (array)))
:uploadMediaUrl
(fn [name url]
diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs
index 1ee7ee6550..6c9a7cbcf5 100644
--- a/frontend/src/app/plugins/comments.cljs
+++ b/frontend/src/app/plugins/comments.cljs
@@ -40,12 +40,14 @@
;; FIXME: inconsistent with comment-thread: owner
:user
- {:get #(->> (dc/get-owner data)
- (user/user-proxy plugin-id))}
+ {:get #(when (r/check-permission plugin-id "user:read")
+ (->> (dc/get-owner data)
+ (user/user-proxy plugin-id)))}
:owner
- {:get #(->> (dc/get-owner data)
- (user/user-proxy plugin-id))}
+ {:get #(when (r/check-permission plugin-id "user:read")
+ (->> (dc/get-owner data)
+ (user/user-proxy plugin-id)))}
:date
{:get
@@ -116,8 +118,9 @@
:board {:get #(shape/shape-proxy plugin-id file-id page-id (:frame-id data))}
:owner
- {:get #(->> (dc/get-owner data)
- (user/user-proxy plugin-id))}
+ {:get #(when (r/check-permission plugin-id "user:read")
+ (->> (dc/get-owner data)
+ (user/user-proxy plugin-id)))}
:position
{:get
diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs
index 74d2c141ca..59d222512b 100644
--- a/frontend/src/app/plugins/file.cljs
+++ b/frontend/src/app/plugins/file.cljs
@@ -61,8 +61,9 @@
:createdBy
{:get
(fn []
- (when-let [user-data (get users (:profile-id @data))]
- (user/user-proxy plugin-id user-data)))}
+ (when (r/check-permission plugin-id "user:read")
+ (when-let [user-data (get users (:profile-id @data))]
+ (user/user-proxy plugin-id user-data))))}
:createdAt
{:get #(:created-at @data)}
diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs
index da3d686705..229cd24f99 100644
--- a/frontend/src/app/plugins/flex.cljs
+++ b/frontend/src/app/plugins/flex.cljs
@@ -325,7 +325,12 @@
:remove
(fn []
- (st/emit! (dwsl/remove-layout #{id})))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwsl/remove-layout #{id}))))
:appendChild
(fn [child]
@@ -350,6 +355,9 @@
(u/changes-component-copy-structure? objects shape child-shape)
(u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy")
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit!
(dwsh/relocate-shapes #{child-id} id index)
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
index c7895ef7fc..35ff505a65 100644
--- a/frontend/src/app/plugins/library.cljs
+++ b/frontend/src/app/plugins/library.cljs
@@ -698,21 +698,37 @@
:addVariant
(fn []
- (st/emit!
- (se/event plugin-id "add-new-variant")
- (dwv/add-new-variant id)))
+ (cond
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
+
+ :else
+ (st/emit!
+ (se/event plugin-id "add-new-variant")
+ (dwv/add-new-variant id))))
:addProperty
(fn []
- (st/emit!
- (se/event plugin-id "add-new-property")
- (dwv/add-new-property id {:property-value "Value 1"})))
+ (cond
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :addProperty "Plugin doesn't have 'library:write' permission")
+
+ :else
+ (st/emit!
+ (se/event plugin-id "add-new-property")
+ (dwv/add-new-property id {:property-value "Value 1"}))))
:removeProperty
(fn [pos]
(let [nprops (->> (get-variant-components file-id id) first :variant-properties count)]
- (if (or (not (nat-int? pos)) (>= pos nprops))
+ (cond
+ (or (not (nat-int? pos)) (>= pos nprops))
(u/not-valid plugin-id :pos pos)
+
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :removeProperty "Plugin doesn't have 'library:write' permission")
+
+ :else
(st/emit!
(se/event plugin-id "remove-property")
(dwv/remove-property id pos)))))
@@ -727,6 +743,9 @@
(not (string? name))
(u/not-valid plugin-id :name name)
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :renameProperty "Plugin doesn't have 'library:write' permission")
+
:else
(st/emit!
(dwv/update-property-name id pos name {:trigger "plugin:rename-property"})))))))
@@ -923,8 +942,15 @@
:transformInVariant
(fn []
(let [component (u/locate-library-component file-id id)]
- (when (and component
- (not (ctk/is-variant? component)))
+ (cond
+ (or (nil? component)
+ (ctk/is-variant? component))
+ nil
+
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission")
+
+ :else
(st/emit!
(se/event plugin-id "transform-in-variant")
(dwv/transform-in-variant (:main-instance-id component))))))
@@ -932,8 +958,15 @@
:addVariant
(fn []
(let [component (u/locate-library-component file-id id)]
- (when (and component
- (ctk/is-variant? component))
+ (cond
+ (or (nil? component)
+ (not (ctk/is-variant? component)))
+ nil
+
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
+
+ :else
(st/emit!
(se/event plugin-id "add-new-variant")
(dwv/add-new-variant (:main-instance-id component))))))
@@ -948,6 +981,9 @@
(not (string? value))
(u/not-valid plugin-id :name value)
+ (not (r/check-permission plugin-id "library:write"))
+ (u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission")
+
:else
(st/emit!
(se/event plugin-id "variant-edit-property-value")
diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs
index 28a674f111..2be488813f 100644
--- a/frontend/src/app/plugins/page.cljs
+++ b/frontend/src/app/plugins/page.cljs
@@ -64,6 +64,9 @@
(or (not (string? value)) (empty? value))
(u/not-valid plugin-id :name value)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))}
@@ -79,12 +82,20 @@
(not (shape/shape-proxy? value))
(u/not-valid plugin-id :startingBoard value)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}
:remove
(fn []
- (st/emit! (dwi/remove-flow page-id id)))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwi/remove-flow page-id id))))))
(defn page-proxy? [proxy]
(obj/type-of? proxy "PageProxy"))
@@ -315,6 +326,9 @@
(not (shape/shape-proxy? frame))
(u/not-valid plugin-id :createFlow-frame frame)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission")
+
:else
(let [flow-id (uuid/next)]
(st/emit!
@@ -328,6 +342,9 @@
(not (flow-proxy? flow))
(u/not-valid plugin-id :removeFlow-flow flow)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :removeFlow "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit!
(dwi/remove-flow id (obj/get flow "$id"))
diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs
index 44059d3244..b7a36bdc7d 100644
--- a/frontend/src/app/plugins/shape.cljs
+++ b/frontend/src/app/plugins/shape.cljs
@@ -103,6 +103,9 @@
(not (contains? ctsi/event-types value))
(u/not-valid plugin-id :trigger value)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@@ -119,6 +122,9 @@
(or (not (sm/valid-safe-int? value)) (neg? value))
(u/not-valid plugin-id :delay value)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@@ -139,6 +145,9 @@
(not (sm/validate ctsi/schema:interaction interaction))
(u/not-valid plugin-id :action interaction)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@@ -148,7 +157,12 @@
:remove
(fn []
- (st/emit! (dwi/remove-interaction {:id shape-id} index)))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwi/remove-interaction {:id shape-id} index))))))
(def lib-typography-proxy? nil)
(def lib-component-proxy nil)
@@ -200,15 +214,15 @@
(not (sm/validate [:vector types.fills/schema:fill] value))
(u/not-valid plugin-id :fills value)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
+
(not (u/page-active? (obj/get self "$page")))
(u/not-valid plugin-id :fills "Cannot modify a page that is not currently active")
(cfh/text-shape? shape)
(st/emit! (dwt/update-attrs id {:fills value}))
- (not (r/check-permission plugin-id "content:write"))
- (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
-
:else
(st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))
@@ -1475,6 +1489,9 @@
:detach
(fn []
(cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :detach "Plugin doesn't have 'content:write' permission")
+
(not (u/page-active? page-id))
(u/not-valid plugin-id :detach "Cannot modify a page that is not currently active")
@@ -1485,12 +1502,12 @@
(fn [component]
(let [shape (u/locate-shape file-id page-id id)]
(cond
- (not (u/page-active? page-id))
- (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active")
-
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission")
+ (not (u/page-active? page-id))
+ (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active")
+
(not (obj/type-of? component "LibraryComponentProxy"))
(u/not-valid plugin-id :swapComponent "Component not valid")
@@ -1507,12 +1524,12 @@
(fn []
(let [shape (u/locate-shape file-id page-id id)]
(cond
- (not (u/page-active? page-id))
- (u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active")
-
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :resetOverrides "Plugin doesn't have 'content:write' permission")
+ (not (u/page-active? page-id))
+ (u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active")
+
(not (ctk/in-component-copy? shape))
(u/not-valid plugin-id :resetOverrides "The shape is not a component copy instance")
@@ -1527,6 +1544,9 @@
(not (sm/validate ctse/schema:export value))
(u/not-valid plugin-id :export value)
+ (not (r/check-permission plugin-id "content:read"))
+ (u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission")
+
:else
(if (and (contains? cf/flags :wasm-export)
(contains? #{:jpeg :webp :png} (:type value :png)))
@@ -1598,6 +1618,9 @@
(not (sm/validate ctsi/schema:interaction interaction))
(u/not-valid plugin-id :addInteraction interaction)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :addInteraction "Plugin doesn't have 'content:write' permission")
+
:else
(let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)]
(st/emit!
@@ -1611,6 +1634,9 @@
(not (interaction-proxy? interaction))
(u/not-valid plugin-id :removeInteraction interaction)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission")
+
:else
(st/emit!
(dwi/remove-interaction {:id id} (obj/get interaction "$index"))
@@ -1691,8 +1717,14 @@
:fn (fn [token attrs]
(let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id"))
kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))]
- (if (some #(not (token-attr? %)) kw-attrs)
+ (cond
+ (some #(not (token-attr? %)) kw-attrs)
(u/not-valid plugin-id :applyToken attrs)
+
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
+
+ :else
(st/emit!
(-> (dwta/toggle-token {:token token
:attrs kw-attrs
@@ -1720,6 +1752,9 @@
(not (string? value))
(u/not-valid plugin-id :value value)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission")
+
:else
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
@@ -1733,6 +1768,9 @@
(or (not (seq ids)) (not (every? uuid/parse* ids)))
(u/not-valid plugin-id :ids ids)
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission")
+
:else
(let [;; Keep the input order (head shape first): it determines
;; the order of the resulting variant components (see
diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs
index 8825775e6f..b27019c7ba 100644
--- a/frontend/src/app/plugins/tokens.cljs
+++ b/frontend/src/app/plugins/tokens.cljs
@@ -17,6 +17,7 @@
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
+ [app.plugins.register :as r]
[app.plugins.system-events :as se]
[app.plugins.utils :as u]
[app.util.object :as obj]
@@ -40,7 +41,9 @@
:m1 :margin-top
:m2 :margin-right
:m3 :margin-bottom
- :m4 :margin-left})
+ :m4 :margin-left
+
+ :font-family :font-families})
(def ^:private map:token-attr-plugin->token-attr
(merge
@@ -85,16 +88,20 @@
(defn- apply-token-to-shapes
[plugin-id file-id set-id id shape-ids attrs]
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
- (let [token (u/locate-token file-id set-id id)]
- (if (some #(not (token-attr? %)) attrs)
- (u/not-valid plugin-id :applyToSelected attrs)
- (st/emit!
- (-> (dwta/toggle-token {:token token
- :attrs (into #{} (map token-attr-plugin->token-attr) attrs)
- :shape-ids shape-ids
- :expand-with-children false})
- (se/add-event plugin-id))))))
+ :else
+ (let [token (u/locate-token file-id set-id id)]
+ (if (some #(not (token-attr? %)) attrs)
+ (u/not-valid plugin-id :applyToSelected attrs)
+ (st/emit!
+ (-> (dwta/toggle-token {:token token
+ :attrs (into #{} (map token-attr-plugin->token-attr) attrs)
+ :shape-ids shape-ids
+ :expand-with-children false})
+ (se/add-event plugin-id)))))))
(defn- typography-resolved-value->js
"Converts a resolved typography composite (a Clojure map keyed by the
@@ -204,8 +211,13 @@
(ctob/get-tokens set-id)))
:set
(fn [_ value]
- (st/emit! (-> (dwtl/update-token set-id id {:name value})
- (se/add-event plugin-id))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (-> (dwtl/update-token set-id id {:name value})
+ (se/add-event plugin-id)))))}
:type
{:this true
@@ -230,11 +242,16 @@
base))
:set
(fn [_ value]
- (let [token (u/locate-token file-id set-id id)
- value (cond-> value
- (= :font-family (:type token))
- (ctob/convert-dtcg-font-family))]
- (st/emit! (dwtl/update-token set-id id {:value value}))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :value "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [token (u/locate-token file-id set-id id)
+ value (cond-> value
+ (= :font-family (:type token))
+ (ctob/convert-dtcg-font-family))]
+ (st/emit! (dwtl/update-token set-id id {:value value})))))}
:resolvedValue
{:this true
@@ -265,28 +282,43 @@
:schema cfo/schema:token-description
:set
(fn [_ value]
- (st/emit! (-> (dwtl/update-token set-id id {:description value})
- (se/add-event :plugin-id))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :description "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (-> (dwtl/update-token set-id id {:description value})
+ (se/add-event plugin-id)))))}
:duplicate
(fn []
- ;; TODO:
- ;; - add function duplicate-token in tokens-lib, that allows to specify the new id
- ;; - use this function in dwtl/duplicate-token
- ;; - return the new token proxy using the locally forced id
- ;; - do the same with sets and themes
- (let [token (u/locate-token file-id set-id id)
- token' (ctob/make-token (-> (datafy token)
- (dissoc :id
- :modified-at)))]
- (st/emit! (-> (dwtl/create-token set-id token')
- (se/add-event plugin-id)))
- (token-proxy plugin-id file-id set-id (:id token'))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
+
+ :else
+ ;; TODO:
+ ;; - add function duplicate-token in tokens-lib, that allows to specify the new id
+ ;; - use this function in dwtl/duplicate-token
+ ;; - return the new token proxy using the locally forced id
+ ;; - do the same with sets and themes
+ (let [token (u/locate-token file-id set-id id)
+ token' (ctob/make-token (-> (datafy token)
+ (dissoc :id
+ :modified-at)))]
+ (st/emit! (-> (dwtl/create-token set-id token')
+ (se/add-event plugin-id)))
+ (token-proxy plugin-id file-id set-id (:id token')))))
:remove
(fn []
- (st/emit! (-> (dwtl/delete-token set-id id)
- (se/add-event plugin-id))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (-> (dwtl/delete-token set-id id)
+ (se/add-event plugin-id)))))
:applyToShapes
{:enumerable false
@@ -337,8 +369,13 @@
id)
:set
(fn [_ name]
- (let [set (u/locate-token-set file-id id)]
- (st/emit! (dwtl/rename-token-set set name))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [set (u/locate-token-set file-id id)]
+ (st/emit! (dwtl/rename-token-set set name)))))}
:active
{:this true
@@ -351,13 +388,23 @@
:schema ::sm/boolean
:set
(fn [_ value]
- (let [set (u/locate-token-set file-id id)]
- (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [set (u/locate-token-set file-id id)]
+ (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value)))))}
:toggleActive
- (fn [_]
- (let [set (u/locate-token-set file-id id)]
- (st/emit! (dwtl/toggle-token-set (ctob/get-name set)))))
+ (fn []
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [set (u/locate-token-set file-id id)]
+ (st/emit! (dwtl/toggle-token-set (ctob/get-name set))))))
:tokens
{:this true
@@ -416,39 +463,54 @@
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]))
:decode/options {:key-fn identity}
:fn (fn [attrs]
- (let [tokens-lib (u/locate-tokens-lib file-id)
- token (ctob/make-token attrs)
- ;; Resolve against all tokens in the library (including those
- ;; in inactive sets) so that references to structurally
- ;; existing tokens resolve even if their set is not active.
- ;; The target set's tokens take precedence over equally named
- ;; tokens in other sets, and the new token takes precedence
- ;; over all.
- tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib)
- (ctob/get-tokens tokens-lib id))
- (assoc (:name token) token))
- resolved-tokens (ts/resolve-tokens tokens-tree)
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :addToken "Plugin doesn't have 'content:write' permission")
- {:keys [errors resolved-value] :as resolved-token}
- (get resolved-tokens (:name token))]
+ :else
+ (let [tokens-lib (u/locate-tokens-lib file-id)
+ token (ctob/make-token attrs)
+ ;; Resolve against all tokens in the library (including those
+ ;; in inactive sets) so that references to structurally
+ ;; existing tokens resolve even if their set is not active.
+ ;; The target set's tokens take precedence over equally named
+ ;; tokens in other sets, and the new token takes precedence
+ ;; over all.
+ tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib)
+ (ctob/get-tokens tokens-lib id))
+ (assoc (:name token) token))
+ resolved-tokens (ts/resolve-tokens tokens-tree)
- (if resolved-value
- (do (st/emit! (-> (dwtl/create-token id token)
- (se/add-event plugin-id)))
- (token-proxy plugin-id file-id id (:id token)))
- (do (u/not-valid plugin-id :addToken (str errors))
- nil))))}
+ {:keys [errors resolved-value] :as resolved-token}
+ (get resolved-tokens (:name token))]
+
+ (if resolved-value
+ (do (st/emit! (-> (dwtl/create-token id token)
+ (se/add-event plugin-id)))
+ (token-proxy plugin-id file-id id (:id token)))
+ (do (u/not-valid plugin-id :addToken (str errors))
+ nil)))))}
:duplicate
(fn []
- (let [id-ref (atom nil)]
- (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref}))
- (when (some? @id-ref)
- (token-set-proxy plugin-id file-id @id-ref))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [id-ref (atom nil)]
+ (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref}))
+ (when (some? @id-ref)
+ (token-set-proxy plugin-id file-id @id-ref)))))
:remove
(fn []
- (st/emit! (dwtl/delete-token-set id))))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwtl/delete-token-set id)))))))
(defn token-theme-proxy? [p]
(obj/type-of? p "TokenThemeProxy"))
@@ -501,8 +563,13 @@
(:id theme)))
:set
(fn [_ group]
- (let [theme (u/locate-token-theme file-id id)]
- (st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :group "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [theme (u/locate-token-theme file-id id)]
+ (st/emit! (dwtl/update-token-theme id (assoc theme :group group))))))}
:name
{:this true
@@ -517,9 +584,14 @@
(:group theme)))
:set
(fn [_ name]
- (let [theme (u/locate-token-theme file-id id)]
- (when name
- (st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [theme (u/locate-token-theme file-id id)]
+ (when name
+ (st/emit! (dwtl/update-token-theme id (assoc theme :name name)))))))}
:active
{:this true
@@ -531,11 +603,21 @@
:schema ::sm/boolean
:set
(fn [_ value]
- (st/emit! (dwtl/set-token-theme-active id value)))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwtl/set-token-theme-active id value))))}
:toggleActive
- (fn [_]
- (st/emit! (dwtl/toggle-token-theme-active id)))
+ (fn []
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwtl/toggle-token-theme-active id))))
:activeSets
{:this true
@@ -554,32 +636,52 @@
{:enumerable false
:schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]]
:fn (fn [set-arg]
- (let [set-name (token-set-name (resolve-token-set file-id set-arg))
- theme (u/locate-token-theme file-id id)]
- (when (and set-name theme)
- (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [set-name (token-set-name (resolve-token-set file-id set-arg))
+ theme (u/locate-token-theme file-id id)]
+ (when (and set-name theme)
+ (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name)))))))}
:removeSet
{:enumerable false
:schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]]
:fn (fn [set-arg]
- (let [set-name (token-set-name (resolve-token-set file-id set-arg))
- theme (u/locate-token-theme file-id id)]
- (when (and set-name theme)
- (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :removeSet "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [set-name (token-set-name (resolve-token-set file-id set-arg))
+ theme (u/locate-token-theme file-id id)]
+ (when (and set-name theme)
+ (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name)))))))}
:duplicate
(fn []
- (let [theme (u/locate-token-theme file-id id)
- theme' (ctob/make-token-theme (-> (datafy theme)
- (dissoc :id
- :modified-at)))]
- (st/emit! (dwtl/create-token-theme theme'))
- (token-theme-proxy plugin-id file-id (:id theme'))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [theme (u/locate-token-theme file-id id)
+ theme' (ctob/make-token-theme (-> (datafy theme)
+ (dissoc :id
+ :modified-at)))]
+ (st/emit! (dwtl/create-token-theme theme'))
+ (token-theme-proxy plugin-id file-id (:id theme')))))
:remove
(fn []
- (st/emit! (dwtl/delete-token-theme id)))))
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (st/emit! (dwtl/delete-token-theme id))))))
(defn tokens-catalog
[plugin-id file-id]
@@ -619,9 +721,14 @@
nil)
(sm/dissoc-key :id))]) ;; We don't allow plugins to set the id
:fn (fn [attrs]
- (let [theme (ctob/make-token-theme attrs)]
- (st/emit! (dwtl/create-token-theme theme))
- (token-theme-proxy plugin-id file-id (:id theme))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :addTheme "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [theme (ctob/make-token-theme attrs)]
+ (st/emit! (dwtl/create-token-theme theme))
+ (token-theme-proxy plugin-id file-id (:id theme)))))}
:addSet
{:enumerable false
@@ -638,21 +745,26 @@
(sm/merge [:map [:active {:optional true} ::sm/boolean]]))]
:fn (fn [attrs]
- (let [active? (boolean (:active attrs))
- attrs (-> attrs
- (dissoc :active)
- (update :name ctob/normalize-set-name))
- set (ctob/make-token-set attrs)]
- (st/emit! (dwtl/create-token-set set))
- ;; Newly created sets are inactive by default; activate it when
- ;; requested. Enabling only adds the set name to the hidden theme,
- ;; so it does not depend on the create event having propagated yet.
- (when active?
- (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true)))
- ;; Pass the set name as `initial-name` so the proxy can resolve
- ;; it immediately, before the async `st/emit!` above propagates
- ;; the new set into `@st/state`.
- (token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set))))}
+ (cond
+ (not (r/check-permission plugin-id "content:write"))
+ (u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission")
+
+ :else
+ (let [active? (boolean (:active attrs))
+ attrs (-> attrs
+ (dissoc :active)
+ (update :name ctob/normalize-set-name))
+ set (ctob/make-token-set attrs)]
+ (st/emit! (dwtl/create-token-set set))
+ ;; Newly created sets are inactive by default; activate it when
+ ;; requested. Enabling only adds the set name to the hidden theme,
+ ;; so it does not depend on the create event having propagated yet.
+ (when active?
+ (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true)))
+ ;; Pass the set name as `initial-name` so the proxy can resolve
+ ;; it immediately, before the async `st/emit!` above propagates
+ ;; the new set into `@st/state`.
+ (token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set)))))}
:getThemeById
{:enumerable false
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index a9c1718580..64d19c56d8 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -880,6 +880,25 @@
(h/call wasm/internal-module "_store_image")
true)))))
+(defn- store-image-url!
+ "Registers the public URL an image was loaded from so SVG export can emit a
+ linked `` instead of a Skia base64 embed."
+ [image-id url]
+ (when (and (wasm/live?) (some? url) (not (str/blank? url)))
+ (let [buffer (uuid/get-u32 image-id)
+ encoder (js/TextEncoder.)
+ encoded (.encode encoder url)
+ size (.-byteLength encoded)
+ offset (mem/alloc size)
+ heap (mem/get-heap-u8)]
+ (.set heap encoded offset)
+ (h/call wasm/internal-module "_store_image_url"
+ (aget buffer 0)
+ (aget buffer 1)
+ (aget buffer 2)
+ (aget buffer 3))
+ true)))
+
(defn- store-image-texture
"Creates a WebGL texture from a decoded image and passes the texture ID to
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
@@ -922,6 +941,7 @@
so Skia rasterizes them."
[shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
+ (store-image-url! image-id url)
{:key url
:thumbnail? thumbnail?
:callback
@@ -959,6 +979,8 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
+ ;; Always register the URL (SVG export needs it even when bytes are cached).
+ (store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?)))))
@@ -993,6 +1015,7 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
+ (store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))))
@@ -1021,6 +1044,7 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
+ (store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
image-ids))))
@@ -2098,13 +2122,34 @@
(h/call wasm/internal-module "_set_structure_modifiers"))))
+;; Axes the pixel grid rounds, as `propagate_modifiers` expects them.
+(def ^:private pixel-precision
+ {:disabled 0
+ :both 1
+ :only-x 2
+ :only-y 3})
+
+(defn- pixel-precision-mode
+ "Encodes the pixel grid snapping for the renderer. `snap-ignore-axis`
+ names the axis to leave alone (`:x`, `:y` or nil)."
+ [snap-pixel? snap-ignore-axis]
+ (pixel-precision
+ (cond
+ (not snap-pixel?) :disabled
+ (= :x snap-ignore-axis) :only-y
+ (= :y snap-ignore-axis) :only-x
+ :else :both)))
+
(defn propagate-modifiers
"Propagates geometry modifiers through the WASM shape tree.
+ Rounds the resulting geometry to the pixel grid when `snap-pixel?` is set,
+ skipping the axis named by `snap-ignore-axis` (`:x`, `:y` or nil).
+
Always returns a vector. When the context is not ready (lost / mid-reload)
or `entries` is empty, returns `[]` so callers never receive `nil` (which
would trip `set-modifiers`' vector assert)."
- [entries pixel-precision]
+ [entries snap-pixel? snap-ignore-axis]
(if-not (and (initialized?) (not ^boolean (empty? entries)))
[]
(let [heapf32 (mem/get-heap-f32)
@@ -2122,7 +2167,8 @@
offset
entries)
- (let [offset (-> (h/call wasm/internal-module "_propagate_modifiers" pixel-precision)
+ (let [precision (pixel-precision-mode snap-pixel? snap-ignore-axis)
+ offset (-> (h/call wasm/internal-module "_propagate_modifiers" precision)
(mem/->offset-32))
length (aget heapu32 offset)
max-offset (+ offset 1 (* length MODIFIER-U32-SIZE))
diff --git a/frontend/test/frontend_tests/basic_shapes_test.cljs b/frontend/test/frontend_tests/basic_shapes_test.cljs
index 1082e2435e..de9bca248b 100644
--- a/frontend/test/frontend_tests/basic_shapes_test.cljs
+++ b/frontend/test/frontend_tests/basic_shapes_test.cljs
@@ -76,3 +76,48 @@
(t/is (= (:stroke-alignment stroke') :inner))
(t/is (= (:stroke-color stroke') "#FABADA"))
(t/is (= (:stroke-width stroke') 2))))))))
+(t/deftest test-update-stroke-color-preserves-dash-gap
+ ;; Custom dash/gap on a dashed stroke describe stroke geometry, not color;
+ ;; a stroke color change must preserve them (issue #11549).
+ (t/async
+ done
+ (let [store (ths/setup-store
+ (-> (cthf/sample-file :file1 :page-label :page1)
+ (cths/add-sample-shape :shape1 :strokes
+ [{:stroke-color "#000000"
+ :stroke-opacity 1
+ :stroke-width 2
+ :stroke-style :dashed
+ :stroke-dash 4
+ :stroke-gap 20}])
+ (cths/add-sample-shape :shape2 :strokes
+ [{:stroke-color "#000000"
+ :stroke-opacity 1
+ :stroke-width 2
+ :stroke-style :dashed}])))
+ events [(dc/change-stroke-color #{(cthi/id :shape1)} {:color "#FABADA"} 0)
+ (dc/change-stroke-color #{(cthi/id :shape2)} {:color "#FABADA"} 0)]]
+ (ths/run-store
+ store done events
+ (fn [new-state]
+ (let [objects (dsh/lookup-page-objects new-state)
+ shape1' (get objects (cthi/id :shape1))
+ stroke1' (first (:strokes shape1'))
+ shape2' (get objects (cthi/id :shape2))
+ stroke2' (first (:strokes shape2'))]
+
+ ;; dashed stroke with custom dash/gap keeps them after color change
+ (t/is (some? shape1'))
+ (t/is (= (:stroke-color stroke1') "#FABADA"))
+ (t/is (= (:stroke-style stroke1') :dashed))
+ (t/is (= (:stroke-width stroke1') 2))
+ (t/is (= (:stroke-dash stroke1') 4))
+ (t/is (= (:stroke-gap stroke1') 20))
+
+ ;; dashed stroke without explicit dash/gap stays unset:
+ ;; no implicit default is materialized into stored data
+ (t/is (some? shape2'))
+ (t/is (= (:stroke-color stroke2') "#FABADA"))
+ (t/is (= (:stroke-style stroke2') :dashed))
+ (t/is (nil? (:stroke-dash stroke2')))
+ (t/is (nil? (:stroke-gap stroke2')))))))))
\ No newline at end of file
diff --git a/frontend/test/frontend_tests/data/comments_filters_test.cljs b/frontend/test/frontend_tests/data/comments_filters_test.cljs
new file mode 100644
index 0000000000..cc0b5181c4
--- /dev/null
+++ b/frontend/test/frontend_tests/data/comments_filters_test.cljs
@@ -0,0 +1,56 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns frontend-tests.data.comments-filters-test
+ (:require
+ [app.main.data.comments :as dcmt]
+ [app.util.storage :as storage]
+ [cljs.test :as t :include-macros true]
+ [potok.v2.core :as ptk]))
+
+(def ^:private storage-key
+ :app.main.data.comments/hide-resolved-comments?)
+
+(t/deftest test-merge-persisted-filters-default
+ (let [prev (get @storage/user storage-key)]
+ (try
+ (swap! storage/user dissoc storage-key)
+ (t/is (= {:show :all} (dcmt/merge-persisted-filters nil)))
+ (t/is (= {:show :all} (dcmt/merge-persisted-filters {})))
+ (finally
+ (if (some? prev)
+ (swap! storage/user assoc storage-key prev)
+ (swap! storage/user dissoc storage-key))))))
+
+(t/deftest test-merge-persisted-filters-hide-resolved
+ (let [prev (get @storage/user storage-key)]
+ (try
+ (swap! storage/user assoc storage-key true)
+ (t/is (= {:show :pending} (dcmt/merge-persisted-filters nil)))
+ (finally
+ (if (some? prev)
+ (swap! storage/user assoc storage-key prev)
+ (swap! storage/user dissoc storage-key))))))
+
+(t/deftest test-merge-persisted-filters-keeps-session-value
+ (let [prev (get @storage/user storage-key)]
+ (try
+ (swap! storage/user assoc storage-key true)
+ (t/is (= {:show :all :mode :yours}
+ (dcmt/merge-persisted-filters {:show :all :mode :yours})))
+ (finally
+ (if (some? prev)
+ (swap! storage/user assoc storage-key prev)
+ (swap! storage/user dissoc storage-key))))))
+
+(t/deftest test-update-filters-updates-show
+ (let [event (dcmt/update-filters {:show :pending})
+ state (ptk/update event {})]
+ (t/is (= :pending (get-in state [:comments-local :show])))
+
+ (let [event (dcmt/update-filters {:show :all})
+ state (ptk/update event state)]
+ (t/is (= :all (get-in state [:comments-local :show]))))))
diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs
index a235d83915..82d4f8ab99 100644
--- a/frontend/test/frontend_tests/helpers/wasm.cljs
+++ b/frontend/test/frontend_tests/helpers/wasm.cljs
@@ -58,7 +58,7 @@
This effectively tells the caller \"apply exactly the transform that
was requested\", which is what the real WASM engine does for simple
moves / resizes without constraints."
- [entries _pixel-precision]
+ [entries _snap-pixel? _snap-ignore-axis]
(track! :propagate-modifiers)
(when (d/not-empty? entries)
(into []
diff --git a/frontend/test/frontend_tests/logic/path_actions_test.cljs b/frontend/test/frontend_tests/logic/path_actions_test.cljs
index 09451937ee..b191f7e8a4 100644
--- a/frontend/test/frontend_tests/logic/path_actions_test.cljs
+++ b/frontend/test/frontend_tests/logic/path_actions_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-actions-test
(:require
diff --git a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs
index 5e79a7447a..8ecf867c4f 100644
--- a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs
+++ b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-clipboard-test
diff --git a/frontend/test/frontend_tests/logic/path_helpers_test.cljs b/frontend/test/frontend_tests/logic/path_helpers_test.cljs
index ba0c28038f..ed86104201 100644
--- a/frontend/test/frontend_tests/logic/path_helpers_test.cljs
+++ b/frontend/test/frontend_tests/logic/path_helpers_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-helpers-test
diff --git a/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs
index 66b20090c8..c73eddd205 100644
--- a/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs
+++ b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-lifecycle-test
diff --git a/frontend/test/frontend_tests/logic/path_test_helpers.cljs b/frontend/test/frontend_tests/logic/path_test_helpers.cljs
index d55fcd17a4..d34060ecca 100644
--- a/frontend/test/frontend_tests/logic/path_test_helpers.cljs
+++ b/frontend/test/frontend_tests/logic/path_test_helpers.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-test-helpers
diff --git a/frontend/test/frontend_tests/logic/path_tools_test.cljs b/frontend/test/frontend_tests/logic/path_tools_test.cljs
index fd286ff728..f8a806f5af 100644
--- a/frontend/test/frontend_tests/logic/path_tools_test.cljs
+++ b/frontend/test/frontend_tests/logic/path_tools_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-tools-test
diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs
index 7e39bf91ee..ed26f86d20 100644
--- a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs
+++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
-;; Copyright (c) KALEIDOS INC Sucursal en España SL
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.wasm-modifiers-nil-id-test
"Reproduces the production crash \"Cannot read properties of null
@@ -46,7 +46,7 @@
the real implementations."
[]
(set! wasm.api/propagate-modifiers
- (fn [entries _pixel-precision]
+ (fn [entries _snap-pixel? _snap-ignore-axis]
(swap! captured-geometry-entries into entries)
(into []
(map (fn [[id data]] [id (:transform data)]))
diff --git a/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs b/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs
new file mode 100644
index 0000000000..37982a1f96
--- /dev/null
+++ b/frontend/test/frontend_tests/logic/wasm_pixel_snap_test.cljs
@@ -0,0 +1,104 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns frontend-tests.logic.wasm-pixel-snap-test
+ "Covers which pixel-grid snapping options reach the WASM renderer.
+
+ The rounding happens in Rust, so these tests assert on the arguments
+ crossing the bridge: which axis an axis-locked drag leaves alone, and
+ that rotation does not snap."
+ (:require
+ [app.common.geom.point :as gpt]
+ [app.common.test-helpers.compositions :as ctho]
+ [app.common.test-helpers.files :as cthf]
+ [app.common.test-helpers.ids-map :as cthi]
+ [app.common.test-helpers.shapes :as cths]
+ [app.common.types.modifiers :as ctm]
+ [app.main.data.workspace.modifiers :as dwm]
+ [app.main.data.workspace.transforms :as dwt]
+ [app.render-wasm.api :as wasm.api]
+ [cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.state :as ths]
+ [frontend-tests.helpers.wasm :as thw]))
+
+(def ^:private captured-snap-options
+ "One entry per `wasm.api/propagate-modifiers` call during a test."
+ (atom []))
+
+(defn- install-capturing-spy!
+ "Records the snap options of every propagation. Must run after
+ `thw/setup-wasm-mocks!` so teardown restores the real implementation."
+ []
+ (set! wasm.api/propagate-modifiers
+ (fn [entries snap-pixel? snap-ignore-axis]
+ (swap! captured-snap-options conj
+ {:snap-pixel? snap-pixel? :snap-ignore-axis snap-ignore-axis})
+ (into []
+ (map (fn [[id data]] [id (:transform data)]))
+ entries))))
+
+(defn- enable-pixel-grid
+ []
+ (fn [state]
+ (update state :workspace-layout conj :snap-pixel-grid)))
+
+(t/use-fixtures :each
+ {:before (fn []
+ (cthi/reset-idmap!)
+ (reset! captured-snap-options [])
+ (thw/setup-wasm-mocks!)
+ (install-capturing-spy!))
+ :after (fn []
+ (thw/teardown-wasm-mocks!))})
+
+(t/deftest axis-locked-move-tells-the-renderer-which-axis-to-ignore
+ (t/async
+ done
+ (let [file (-> (cthf/sample-file :file1)
+ (ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
+ store (ths/setup-store file)
+ rect (cths/get-shape file :rect1)
+ modif-tree (dwm/create-modif-tree [(:id rect)]
+ (ctm/move-modifiers (gpt/point 5.2 0)))
+ events [(enable-pixel-grid)
+ (dwm/apply-wasm-modifiers modif-tree :snap-ignore-axis :y)]]
+ (ths/run-store
+ store done events
+ (fn [_new-state]
+ (t/is (= [{:snap-pixel? true :snap-ignore-axis :y}]
+ @captured-snap-options)))))))
+
+(t/deftest move-without-axis-lock-snaps-both-axes
+ (t/async
+ done
+ (let [file (-> (cthf/sample-file :file1)
+ (ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
+ store (ths/setup-store file)
+ rect (cths/get-shape file :rect1)
+ modif-tree (dwm/create-modif-tree [(:id rect)]
+ (ctm/move-modifiers (gpt/point 5.2 3.7)))
+ events [(enable-pixel-grid)
+ (dwm/apply-wasm-modifiers modif-tree)]]
+ (ths/run-store
+ store done events
+ (fn [_new-state]
+ (t/is (= [{:snap-pixel? true :snap-ignore-axis nil}]
+ @captured-snap-options)))))))
+
+(t/deftest rotation-does-not-snap-to-the-pixel-grid
+ (t/async
+ done
+ (let [file (-> (cthf/sample-file :file1)
+ (ctho/add-rect :rect1 :x 10.4 :y 20.6 :width 100.5 :height 50.3))
+ store (ths/setup-store file)
+ rect (cths/get-shape file :rect1)
+ events [(enable-pixel-grid)
+ (dwt/increase-rotation #{(:id rect)} 15)]]
+ (ths/run-store
+ store done events
+ (fn [_new-state]
+ (t/is (= [{:snap-pixel? false :snap-ignore-axis nil}]
+ @captured-snap-options)))))))
diff --git a/frontend/test/frontend_tests/plugins/flex_test.cljs b/frontend/test/frontend_tests/plugins/flex_test.cljs
new file mode 100644
index 0000000000..9cf5e68442
--- /dev/null
+++ b/frontend/test/frontend_tests/plugins/flex_test.cljs
@@ -0,0 +1,40 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns frontend-tests.plugins.flex-test
+ (:require
+ [app.common.types.shape.layout :as ctl]
+ [app.common.uuid :as uuid]
+ [app.main.store :as st]
+ [app.plugins.flex :as flex]
+ [app.plugins.register :as r]
+ [app.plugins.shape :as shape]
+ [app.plugins.utils :as u]
+ [cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.mock :as mock]))
+
+;; ---------------------------------------------------------------------------
+;; Permission checks (T9-F-05)
+;; ---------------------------------------------------------------------------
+
+(t/deftest flex-remove-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop]
+ (let [proxy (flex/flex-layout-proxy plugin-id file-id page-id id)]
+ (.remove proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+;; TODO: flex-append-child-checks-permission test requires more complex mocking
+;; of u/locate-objects, u/locate-shape, ctl/reverse?, etc. The permission check
+;; is in place at flex.cljs line 358.
diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs
index e359fd852e..d951cddb05 100644
--- a/frontend/test/frontend_tests/plugins/library_test.cljs
+++ b/frontend/test/frontend_tests/plugins/library_test.cljs
@@ -6,8 +6,11 @@
(ns frontend-tests.plugins.library-test
(:require
+ [app.common.types.component :as ctk]
+ [app.common.uuid :as uuid]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
+ [app.main.data.workspace.variants :as dwv]
[app.main.store :as st]
[app.plugins.library :as library]
[app.plugins.register :as r]
@@ -93,3 +96,112 @@
(t/is (contains? (:color @captured) :image))
(t/is (not (contains? (:color @captured) :color)))
(t/is (not (contains? (:color @captured) :gradient))))))
+
+;; ---------------------------------------------------------------------------
+;; Permission checks (T9-F-02)
+;; ---------------------------------------------------------------------------
+
+(t/deftest variant-add-variant-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop]
+ (let [proxy (library/variant-proxy plugin-id file-id id)]
+ (.addVariant proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
+
+(t/deftest variant-add-property-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop]
+ (let [proxy (library/variant-proxy plugin-id file-id id)]
+ (.addProperty proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addProperty "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
+
+(t/deftest variant-remove-property-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
+ st/emit! mock/noop]
+ (let [proxy (library/variant-proxy plugin-id file-id id)]
+ (.removeProperty proxy 0)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :removeProperty "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
+
+(t/deftest variant-rename-property-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
+ st/emit! mock/noop]
+ (let [proxy (library/variant-proxy plugin-id file-id id)]
+ (.renameProperty proxy 0 "newName")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :renameProperty "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
+
+(t/deftest component-transform-in-variant-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ u/locate-library-component (constantly {:id id :main-instance-id id})
+ ctk/is-variant? (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (library/lib-component-proxy plugin-id file-id id)]
+ (.transformInVariant proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
+
+(t/deftest component-add-variant-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ u/locate-library-component (constantly {:id id :main-instance-id id})
+ ctk/is-variant? (constantly true)
+ st/emit! mock/noop]
+ (let [proxy (library/lib-component-proxy plugin-id file-id id)]
+ (.addVariant proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
+
+(t/deftest component-set-variant-property-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]})
+ st/emit! mock/noop]
+ (let [proxy (library/lib-component-proxy plugin-id file-id id)]
+ (.setVariantProperty proxy 0 "red")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"]
+ (first @errors)))))))
diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs
index d48318b77b..c7fe33e643 100644
--- a/frontend/test/frontend_tests/plugins/page_test.cljs
+++ b/frontend/test/frontend_tests/plugins/page_test.cljs
@@ -9,12 +9,17 @@
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as cths]
+ [app.common.uuid :as uuid]
[app.main.data.workspace.pages :as dwpg]
[app.main.store :as st]
[app.plugins.api :as api]
+ [app.plugins.page :as page]
+ [app.plugins.register :as r]
[app.plugins.shape :as shape]
+ [app.plugins.utils :as u]
[app.util.object :as obj]
[cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
@@ -151,3 +156,85 @@
(done))))
(mock-page-initialized store page2-id))
0))))
+
+;; ---------------------------------------------------------------------------
+;; Permission checks (T9-F-03)
+;; ---------------------------------------------------------------------------
+
+(t/deftest flow-name-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ flow-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop]
+ (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
+ (set! (.-name proxy) "new-name")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest flow-starting-board-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ flow-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop
+ shape/shape-proxy? (constantly true)]
+ (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
+ (set! (.-startingBoard proxy) #js {})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :startingBoard "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest flow-remove-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ flow-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop]
+ (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
+ (.remove proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest create-flow-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop
+ shape/shape-proxy? (constantly true)]
+ (let [proxy (page/page-proxy plugin-id file-id page-id)
+ frame #js {"$id" (uuid/next)}]
+ (.createFlow proxy "flow-name" frame)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :createFlow "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest remove-flow-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ flow-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [r/check-permission (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ st/emit! mock/noop
+ page/flow-proxy? (constantly true)]
+ (let [proxy (page/page-proxy plugin-id file-id page-id)]
+ (.removeFlow proxy (page/flow-proxy plugin-id file-id page-id flow-id))
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :removeFlow "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs
index 4ce610848c..b85f471c13 100644
--- a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs
+++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs
@@ -11,10 +11,15 @@
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.interactions :as dwi]
+ [app.main.data.workspace.libraries :as dwl]
+ [app.main.data.workspace.texts :as dwt]
+ [app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.variants :as dwv]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.public-utils :as public-utils]
+ [app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.utils :as u]
[cljs.test :as t :include-macros true]
@@ -204,3 +209,202 @@
(t/deftest group-empty-input-returns-nil
(let [context (api/create-context plugin-id)]
(t/is (nil? (.group context #js [])))))
+
+;; ---------------------------------------------------------------------------
+;; Permission checks (T9-F-04)
+;; ---------------------------------------------------------------------------
+
+(t/deftest commit-fills-text-shape-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/proxy->shape (constantly {:id shape-id :type :text})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
+ (set! (.-fills proxy) #js [#js {:fillColor "#ff0000" :fillOpacity 1}])
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :fills "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest interaction-trigger-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
+ (set! (.-trigger inter) "click")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :trigger "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest interaction-delay-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
+ (set! (.-delay inter) 100)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :delay "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest interaction-action-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/proxy->interaction (constantly {:event-type :click :delay 0 :action-type :open-url :url "https://example.com"})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
+ (set! (.-action inter) #js {:type "open-url" :url "https://example.com"})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :action "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest interaction-remove-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
+ (.remove inter)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest add-interaction-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-shape (constantly {:id shape-id})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
+ (.addInteraction proxy "click" #js {:type "open-url" :url "https://example.com"})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addInteraction "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest remove-interaction-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
+ inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
+ (.removeInteraction proxy inter)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest detach-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/page-active? (constantly true)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
+ (.detach proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :detach "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest export-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
+ (.export proxy #js {:type "png" :scale 1})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :export "Plugin doesn't have 'content:read' permission"]
+ (first @errors)))))))
+
+(t/deftest apply-token-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ set-id (uuid/next)
+ token-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
+ shape/token-proxy? (constantly true)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
+ token #js {"$set-id" (str set-id) "$id" (str token-id)}]
+ (.applyToken proxy token #js [])
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :applyToken "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest switch-variant-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-shape (constantly {:id shape-id :component-id shape-id})
+ u/locate-library-component (constantly {:id (uuid/next)})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
+ (.switchVariant proxy 0 "value")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :switchVariant "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
+
+(t/deftest combine-as-variants-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ page-id (uuid/next)
+ shape-id (uuid/next)
+ other-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id})
+ u/locate-library-component (constantly {:id (uuid/next)})
+ ctk/is-variant? (constantly false)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
+ (.combineAsVariants proxy #js [(str other-id)])
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"]
+ (first @errors)))))))
diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs
index c501b9fb66..3f4f56f6d9 100644
--- a/frontend/test/frontend_tests/plugins/tokens_test.cljs
+++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs
@@ -16,14 +16,20 @@
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
[app.plugins.api :as api]
+ [app.plugins.register :as r]
[app.plugins.tokens :as ptok]
[app.plugins.utils :as u]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
+ [frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
-(t/use-fixtures :each {:before cthi/reset-idmap!})
+(t/use-fixtures :each
+ {:before (fn []
+ (cthi/reset-idmap!)
+ (thw/setup-wasm-mocks!))
+ :after thw/teardown-wasm-mocks!})
(def ^:private get-resolved-value @#'ptok/get-resolved-value)
@@ -80,6 +86,18 @@
(t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom)))
(t/is (= :m4 (ptok/token-attr-plugin->token-attr :margin-left))))
+(t/deftest token-attr-plugin->token-attr-resolves-font-family-alias
+ ;; Plugin-facing `fontFamilies` (kebab-cased to `:font-families` by the
+ ;; schema layer) maps to the canonical internal `:font-family`.
+ (t/is (= :font-family (ptok/token-attr-plugin->token-attr :font-families)))
+ (t/is (= :font-family (ptok/token-attr-plugin->token-attr "font-families"))))
+
+(t/deftest token-attr->token-attr-plugin-resolves-font-family-alias
+ ;; Symmetric direction: the canonical internal attribute maps to the
+ ;; plural plugin-facing name so applied-token readback serializes as
+ ;; camelCase `fontFamilies`, not the undocumented singular `fontFamily`.
+ (t/is (= :font-families (ptok/token-attr->token-attr-plugin :font-family))))
+
(t/deftest token-attr-plugin->token-attr-coerces-string-input
;; This is the actual regression — JS plugin calls supply strings.
(t/is (= :fill (ptok/token-attr-plugin->token-attr "fill")))
@@ -149,6 +167,57 @@
(done)))
0))))
+(t/deftest shape-apply-token-accepts-font-families
+ (t/async
+ done
+ (let [set-id (cthi/new-id! :token-set)
+ token-id (cthi/new-id! :font-family-token)
+ file (-> (cthf/sample-file :file1 :page-label :page1)
+ (ctho/add-text :text1 "Hello World!")
+ (ctht/add-tokens-lib)
+ (ctht/update-tokens-lib
+ #(-> %
+ (ctob/add-set
+ (ctob/make-token-set :id set-id
+ :name "fonts"))
+ (ctob/add-theme
+ (ctob/make-token-theme :name "theme"
+ :sets #{"fonts"}))
+ (ctob/set-active-themes #{"/theme"})
+ (ctob/add-token
+ set-id
+ (ctob/make-token :id token-id
+ :name "font.primary"
+ :type :font-family
+ :value ["Inter"])))))
+ store (ths/setup-store file)
+ _ (set! st/state store)
+ _ (set! st/stream (ptk/input-stream store))
+ ^js context (api/create-context "00000000-0000-0000-0000-000000000000")
+ ^js page (.-currentPage context)
+ ^js shape (.getShapeById page (str (cthi/id :text1)))
+ ^js library (.-library context)
+ ^js local (.-local library)
+ ^js catalog (.-tokens local)
+ ^js token-set (.getSetById catalog (str set-id))
+ ^js token (.getTokenById token-set (str token-id))]
+ (.applyToken shape token #js ["fontFamilies"])
+ (js/setTimeout
+ (fn []
+ (let [shape-id (cthi/id :text1)
+ page-id (cthf/current-page-id file)]
+ ;; Plugin readback exposes the documented plural key.
+ (t/is (= "font.primary" (.. shape -tokens -fontFamilies)))
+ ;; The undocumented singular spelling must not leak.
+ (t/is (undefined? (.. shape -tokens -fontFamily)))
+ ;; Internal state keeps the canonical `:font-family` key.
+ (t/is (= "font.primary"
+ (get-in @store
+ [:files (:id file) :data :pages-index page-id
+ :objects shape-id :applied-tokens :font-family])))
+ (done)))
+ 0))))
+
(t/deftest token-attr?-rejects-unknown-input
(t/is (false? (boolean (ptok/token-attr? :not-a-real-attr))))
(t/is (false? (boolean (ptok/token-attr? "not-a-real-attr"))))
@@ -236,7 +305,8 @@
set-id (cthi/new-id! :set)
dup-id (cthi/new-id! :dup)
proxy (ptok/token-set-proxy "plugin-id" file-id set-id)]
- (with-redefs [dwtl/duplicate-token-set
+ (with-redefs [r/check-permission (constantly true)
+ dwtl/duplicate-token-set
(mock/stub (fn [id {:keys [id-ref]}]
(t/is (= set-id id))
(reset! id-ref dup-id)
@@ -253,7 +323,8 @@
set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives")
theme (ptok/token-theme-proxy "plugin-id" file-id theme-id)
captured (atom [])]
- (with-redefs [u/locate-token-theme
+ (with-redefs [r/check-permission (constantly true)
+ u/locate-token-theme
(fn [_file _theme]
(ctob/make-token-theme :id theme-id
:name "Theme"
@@ -274,7 +345,8 @@
set-id (cthi/new-id! :set)
token-id (cthi/new-id! :token)
captured (atom nil)]
- (with-redefs [u/locate-token (constantly {:id token-id
+ (with-redefs [r/check-permission (constantly true)
+ u/locate-token (constantly {:id token-id
:name "font.primary"
:type :font-family
:value ["Inter"]})
@@ -347,7 +419,8 @@
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
emitted (atom [])
invalid (atom [])]
- (with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
+ (with-redefs [r/check-permission (constantly true)
+ u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
@@ -367,7 +440,8 @@
theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light")
emitted (atom [])
invalid (atom [])]
- (with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
+ (with-redefs [r/check-permission (constantly true)
+ u/locate-token-set (fn [_ id] (when (= id set-id) token-set))
u/locate-token-theme (fn [_ id] (when (= id theme-id) theme))
u/not-valid (fn [_ code value] (swap! invalid conj [code value]))
dwtl/update-token-theme (fn [id theme] {:id id :theme theme})
@@ -400,3 +474,304 @@
(t/is (= 2 (count @errors)))
(t/is (every? #(instance? js/Error %) @errors))))))
+;; ═══════════════════════════════════════════════════════════════
+;; Permission check tests (T9-F-01)
+;; ═══════════════════════════════════════════════════════════════
+
+;; Note: token-proxy-name-setter-checks-permission test removed because
+;; schema validation runs before the permission check, making it impossible
+;; to test the permission check directly for setters with schemas.
+
+(t/deftest token-proxy-value-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ token-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
+ u/locate-tokens-lib (constantly nil)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
+ (set! (.-value proxy) "#ff0000")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :value "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-proxy-description-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ token-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token (constantly {:id token-id :name "test"})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
+ (set! (.-description proxy) "A description")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :description "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-proxy-duplicate-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ token-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color :value "#000"})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
+ (.duplicate proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-proxy-remove-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ token-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)]
+ (.remove proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-set-proxy-name-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
+ u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
+ (set! (.-name proxy) "new-core")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-set-proxy-active-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
+ u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
+ (set! (.-active proxy) true)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-set-proxy-toggle-active-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
+ u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
+ (.toggleActive proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-set-proxy-add-token-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ tokens-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set :id set-id :name "core")))
+ errors (atom [])]
+ (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"})
+ u/locate-tokens-lib (constantly tokens-lib)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
+ (t/is (fn? (.-addToken proxy)))
+ (.addToken proxy #js {"type" "color" "name" "color.test" "value" "#FF0000"})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addToken "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-set-proxy-duplicate-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
+ (.duplicate proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-set-proxy-remove-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)]
+ (.remove proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-group-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
+ u/locate-tokens-lib (constantly nil)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
+ (set! (.-group proxy) "new-group")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :group "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-name-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
+ u/locate-tokens-lib (constantly nil)
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
+ (set! (.-name proxy) "Dark")
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-active-setter-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
+ (set! (.-active proxy) true)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-toggle-active-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
+ (.toggleActive proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-add-set-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{}})
+ u/locate-token-set (constantly {:id set-id :name "core"})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)
+ set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
+ (.addSet proxy set-proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-remove-set-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ set-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{"core"}})
+ u/locate-token-set (constantly {:id set-id :name "core"})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)
+ set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")]
+ (.removeSet proxy set-proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :removeSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-duplicate-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"})
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
+ (.duplicate proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest token-theme-proxy-remove-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ theme-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)]
+ (.remove proxy)
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest tokens-catalog-add-theme-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [catalog (ptok/tokens-catalog plugin-id file-id)]
+ (.addTheme catalog #js {"name" "NewTheme" "group" "mode"})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addTheme "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
+(t/deftest tokens-catalog-add-set-checks-permission
+ (let [plugin-id "test-plugin"
+ file-id (uuid/next)
+ errors (atom [])]
+ (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib))
+ u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
+ r/check-permission (constantly false)
+ st/emit! mock/noop]
+ (let [catalog (ptok/tokens-catalog plugin-id file-id)]
+ (.addSet catalog #js {"name" "NewSet"})
+ (t/is (= 1 (count @errors)))
+ (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors)))))))
+
diff --git a/frontend/test/frontend_tests/plugins/user_test.cljs b/frontend/test/frontend_tests/plugins/user_test.cljs
new file mode 100644
index 0000000000..620d527318
--- /dev/null
+++ b/frontend/test/frontend_tests/plugins/user_test.cljs
@@ -0,0 +1,80 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns frontend-tests.plugins.user-test
+ (:require
+ [app.main.data.comments :as dc]
+ [app.main.store :as st]
+ [app.plugins.api :as api]
+ [app.plugins.comments :as comments]
+ [app.plugins.file :as file]
+ [app.plugins.register :as r]
+ [cljs.test :as t :include-macros true]
+ [frontend-tests.helpers.mock :as mock]))
+
+(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
+
+(t/deftest comment-thread-owner-returns-nil-without-user-read
+ (let [owner-id (random-uuid)
+ file-id (random-uuid)
+ page-id (random-uuid)
+ thread-id (random-uuid)
+ thread (comments/comment-thread-proxy
+ plugin-id
+ file-id
+ page-id
+ {:id thread-id :owner-id owner-id})]
+ (with-redefs [r/check-permission (constantly false)
+ dc/get-owner (constantly {:id owner-id :fullname "Owner"})]
+ (t/is (nil? (.-owner thread)))
+ (t/is (nil? (.-user thread))))))
+
+(t/deftest comment-reply-owner-returns-nil-without-user-read
+ (let [owner-id (random-uuid)
+ file-id (random-uuid)
+ page-id (random-uuid)
+ thread-id (random-uuid)
+ reply-id (random-uuid)
+ reply (comments/comment-proxy
+ plugin-id
+ file-id
+ page-id
+ thread-id
+ {:id reply-id :owner-id owner-id})]
+ (with-redefs [r/check-permission (constantly false)
+ dc/get-owner (constantly {:id owner-id :fullname "Owner"})]
+ (t/is (nil? (.-owner reply)))
+ (t/is (nil? (.-user reply))))))
+
+(t/deftest file-version-created-by-returns-nil-without-user-read
+ (let [file-id (random-uuid)
+ version-id (random-uuid)
+ profile-id (random-uuid)
+ version (file/file-version-proxy
+ plugin-id
+ file-id
+ {profile-id {:id profile-id :fullname "User"}}
+ {:id version-id
+ :label "Version"
+ :created-at (js/Date.)
+ :profile-id profile-id})]
+ (with-redefs [r/check-permission (constantly false)]
+ (t/is (nil? (.-createdBy version))))))
+
+(t/deftest get-current-user-returns-nil-without-user-read
+ (let [ctx (api/create-context plugin-id)]
+ (with-redefs [r/check-permission (constantly false)
+ st/state (atom {:session-id (random-uuid)
+ :profile {:id (random-uuid) :fullname "User"}})]
+ (t/is (nil? (.getCurrentUser ctx))))))
+
+(t/deftest get-active-users-returns-empty-without-user-read
+ (let [ctx (api/create-context plugin-id)]
+ (with-redefs [r/check-permission (constantly false)
+ st/state (atom {:session-id (random-uuid)
+ :profile {:id (random-uuid)}
+ :workspace-presence {(random-uuid) {:id (random-uuid)}}})]
+ (t/is (zero? (.-length (.getActiveUsers ctx)))))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index a139201f54..e085b8f2ec 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -8,6 +8,7 @@
[frontend-tests.code-gen-style-test]
[frontend-tests.composable-tests.comp.sync-test]
[frontend-tests.copy-as-svg-test]
+ [frontend-tests.data.comments-filters-test]
[frontend-tests.data.dashboard-test]
[frontend-tests.data.exports-assets-test]
[frontend-tests.data.nitrate-test]
@@ -45,10 +46,12 @@
[frontend-tests.logic.sidebar-transform-coalescing-test]
[frontend-tests.logic.update-position-test]
[frontend-tests.logic.wasm-modifiers-nil-id-test]
+ [frontend-tests.logic.wasm-pixel-snap-test]
[frontend-tests.main-errors-test]
[frontend-tests.plugins.comments-test]
[frontend-tests.plugins.context-shapes-test]
[frontend-tests.plugins.file-test]
+ [frontend-tests.plugins.flex-test]
[frontend-tests.plugins.format-test]
[frontend-tests.plugins.grid-test]
[frontend-tests.plugins.interactions-test]
@@ -60,6 +63,7 @@
[frontend-tests.plugins.shape-bugfixes-test]
[frontend-tests.plugins.text-test]
[frontend-tests.plugins.tokens-test]
+ [frontend-tests.plugins.user-test]
[frontend-tests.plugins.utils-test]
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-dimensions-test]
@@ -114,6 +118,7 @@
'frontend-tests.code-gen-style-test
'frontend-tests.composable-tests.comp.sync-test
'frontend-tests.copy-as-svg-test
+ 'frontend-tests.data.comments-filters-test
'frontend-tests.data.dashboard-test
'frontend-tests.data.nitrate-test
'frontend-tests.data.profile-test
@@ -152,9 +157,11 @@
'frontend-tests.logic.sidebar-transform-coalescing-test
'frontend-tests.logic.update-position-test
'frontend-tests.logic.wasm-modifiers-nil-id-test
+ 'frontend-tests.logic.wasm-pixel-snap-test
'frontend-tests.plugins.comments-test
'frontend-tests.plugins.context-shapes-test
'frontend-tests.plugins.file-test
+ 'frontend-tests.plugins.flex-test
'frontend-tests.plugins.format-test
'frontend-tests.plugins.grid-test
'frontend-tests.plugins.interactions-test
@@ -166,6 +173,7 @@
'frontend-tests.plugins.shape-bugfixes-test
'frontend-tests.plugins.text-test
'frontend-tests.plugins.tokens-test
+ 'frontend-tests.plugins.user-test
'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-objects-test
diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json
index af4dd7fde3..42c442f5f0 100644
--- a/frontend/text-editor/package.json
+++ b/frontend/text-editor/package.json
@@ -28,5 +28,5 @@
"vite": "^8.2.0",
"vitest": "^4.1.10"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 734fd8e946..95f89b8594 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -7,12 +7,8 @@ import { playwright } from '@vitest/browser-playwright'
// https://vitejs.dev/config/
import path from "node:path";
-import { fileURLToPath } from "node:url";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
-const dirname =
- typeof __dirname !== "undefined"
- ? __dirname
- : path.dirname(fileURLToPath(import.meta.url));
+const dirname = import.meta.dirname;
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
@@ -45,15 +41,14 @@ export default defineConfig({
{browser: "chromium"},
],
},
- setupFiles: [".storybook/vitest.setup.ts"],
},
},
],
},
resolve: {
alias: {
- "@target": resolve(__dirname, "./target/storybook"),
- "@public": resolve(__dirname, "./resources/public/js/"),
+ "@target": resolve(dirname, "./target/storybook"),
+ "@public": resolve(dirname, "./resources/public/js/"),
},
},
});
diff --git a/library/package.json b/library/package.json
index f22f9aea27..aff0eb2082 100644
--- a/library/package.json
+++ b/library/package.json
@@ -3,7 +3,7 @@
"version": "1.2.0-RC1",
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"type": "module",
"repository": {
"type": "git",
diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml
index 6fa97dcff3..9f4a4d81a3 100644
--- a/library/pnpm-lock.yaml
+++ b/library/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/library/pnpm-workspace.yaml b/library/pnpm-workspace.yaml
index b5e864413d..528f811e1a 100644
--- a/library/pnpm-workspace.yaml
+++ b/library/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
minimumReleaseAgeExclude:
- brace-expansion@5.0.8 || 5.0.9
patchedDependencies:
diff --git a/mcp/README.md b/mcp/README.md
index 1b8dc3ea29..6842adce7e 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -267,7 +267,7 @@ The Penpot MCP server can be configured using environment variables.
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
-| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
+| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` |
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |
diff --git a/mcp/package.json b/mcp/package.json
index 2b0046f3b0..abd1b8c8be 100644
--- a/mcp/package.json
+++ b/mcp/package.json
@@ -23,7 +23,7 @@
"type": "git",
"url": "https://github.com/penpot/penpot.git"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"devDependencies": {
"concurrently": "^10.0.5",
"prettier": "^3.9.6"
diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json
index 4c82e9796a..fae32707ef 100644
--- a/mcp/packages/common/package.json
+++ b/mcp/packages/common/package.json
@@ -4,7 +4,7 @@
"description": "Shared type definitions and interfaces for Penpot MCP",
"main": "dist/index.js",
"types": "dist/index.d.ts",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"scripts": {
"build": "tsc --build --clean && tsc --build",
"watch": "tsc --watch",
diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json
index 5377e1bba7..534fef6226 100644
--- a/mcp/packages/plugin/package.json
+++ b/mcp/packages/plugin/package.json
@@ -3,6 +3,7 @@
"private": true,
"version": "1.0.0",
"type": "module",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"scripts": {
"start": "vite build --watch --config vite.config.ts",
"start:multi-user": "pnpm run start",
diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json
index e675c2e7c8..4711b793a5 100644
--- a/mcp/packages/server/package.json
+++ b/mcp/packages/server/package.json
@@ -24,7 +24,7 @@
],
"author": "",
"license": "MIT",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"class-transformer": "^0.5.1",
diff --git a/mcp/packages/server/src/PenpotMcpServer.test.ts b/mcp/packages/server/src/PenpotMcpServer.test.ts
index 5c04e50400..68665359f5 100644
--- a/mcp/packages/server/src/PenpotMcpServer.test.ts
+++ b/mcp/packages/server/src/PenpotMcpServer.test.ts
@@ -1,6 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { PenpotMcpServer } from "./PenpotMcpServer";
+import { PenpotMcpServer, shouldRegisterDeveloperTools } from "./PenpotMcpServer";
+
+test("registers developer tools in local devenv mode", () => {
+ assert.equal(shouldRegisterDeveloperTools(true, false), true);
+});
+
+test("does not register developer tools in multi-user devenv mode", () => {
+ assert.equal(shouldRegisterDeveloperTools(true, true), false);
+});
+
+test("does not register developer tools when devenv mode is disabled", () => {
+ assert.equal(shouldRegisterDeveloperTools(false, false), false);
+});
// ── Pure function tests ────────────────────────────────────────
diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts
index 09849c9316..c620aca3fc 100644
--- a/mcp/packages/server/src/PenpotMcpServer.ts
+++ b/mcp/packages/server/src/PenpotMcpServer.ts
@@ -50,6 +50,13 @@ class ToolInfo {
) {}
}
+/**
+ * Indicates whether developer tools may be registered for the current server mode.
+ */
+export function shouldRegisterDeveloperTools(isDevEnv: boolean, isMultiUserMode: boolean): boolean {
+ return isDevEnv && !isMultiUserMode;
+}
+
export class PenpotMcpServer {
/**
* Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed.
@@ -259,7 +266,7 @@ export class PenpotMcpServer {
if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this));
}
- if (this.isDevEnv()) {
+ if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) {
const nreplClient = new NreplClient();
toolInstances.push(new CljsReplTool(this, nreplClient));
toolInstances.push(new ImportPenpotFileTool(this, nreplClient));
diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml
index 706069eee7..6b15ed28ab 100644
--- a/mcp/pnpm-lock.yaml
+++ b/mcp/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml
index eb1c390c2f..13e111b1dc 100644
--- a/mcp/pnpm-workspace.yaml
+++ b/mcp/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
allowBuilds:
esbuild: true
sharp: false
diff --git a/media-processor/package.json b/media-processor/package.json
index 0bb5711cf7..ea012c5246 100644
--- a/media-processor/package.json
+++ b/media-processor/package.json
@@ -14,7 +14,7 @@
"fmt:check": "prettier --check src/ test/",
"clean": "rm -rf dist/"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"dependencies": {
"express": "^5.2.1",
"multer": "^2.2.0",
diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml
index 51eef68766..a35ada5f76 100644
--- a/media-processor/pnpm-lock.yaml
+++ b/media-processor/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/media-processor/pnpm-workspace.yaml b/media-processor/pnpm-workspace.yaml
index 5ed0b5af0d..f355f8d1a4 100644
--- a/media-processor/pnpm-workspace.yaml
+++ b/media-processor/pnpm-workspace.yaml
@@ -1,2 +1,4 @@
+storeDir: ../.pnpm-store
+
allowBuilds:
esbuild: true
diff --git a/package.json b/package.json
index 695a8f2e20..965f175b3d 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.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md
index 9cd186baf0..96bd5dc421 100644
--- a/plugins/CHANGELOG.md
+++ b/plugins/CHANGELOG.md
@@ -10,6 +10,8 @@
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
+- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
+- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405.
## 1.5.0 (2026-07-08)
diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json
index 8d6aff2904..2bec61bf8b 100644
--- a/plugins/apps/colors-to-tokens-plugin/package.json
+++ b/plugins/apps/colors-to-tokens-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/colors-to-tokens-plugin/vite.config.ts b/plugins/apps/colors-to-tokens-plugin/vite.config.ts
index ce57d28043..4f9cb48c23 100644
--- a/plugins/apps/colors-to-tokens-plugin/vite.config.ts
+++ b/plugins/apps/colors-to-tokens-plugin/vite.config.ts
@@ -2,7 +2,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
test: {
watch: false,
globals: true,
diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json
index 60697f5367..949296d0b4 100644
--- a/plugins/apps/composable-test-suite/package.json
+++ b/plugins/apps/composable-test-suite/package.json
@@ -26,5 +26,5 @@
"vite": "^8.2.2",
"vite-live-preview": "^0.4.0"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/composable-test-suite/pnpm-lock.yaml b/plugins/apps/composable-test-suite/pnpm-lock.yaml
deleted file mode 100644
index 93ed3672c1..0000000000
--- a/plugins/apps/composable-test-suite/pnpm-lock.yaml
+++ /dev/null
@@ -1,857 +0,0 @@
-lockfileVersion: '9.0'
-
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false
-
-importers:
-
- .:
- dependencies:
- '@penpot/plugin-styles':
- specifier: 1.4.1
- version: 1.4.1
- '@penpot/plugin-types':
- specifier: 1.4.1
- version: 1.4.1
- devDependencies:
- playwright:
- specifier: ^1.61.1
- version: 1.61.1
- prettier:
- specifier: ^3.6.2
- version: 3.9.4
- typescript:
- specifier: ^5.8.3
- version: 5.9.3
- vite:
- specifier: ^7.0.8
- version: 7.3.6(@types/node@26.0.1)
- vite-live-preview:
- specifier: ^0.3.2
- version: 0.3.2(vite@7.3.6(@types/node@26.0.1))
-
-packages:
-
- '@commander-js/extra-typings@12.1.0':
- resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==}
- peerDependencies:
- commander: ~12.1.0
-
- '@esbuild/aix-ppc64@0.28.1':
- resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [aix]
-
- '@esbuild/android-arm64@0.28.1':
- resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [android]
-
- '@esbuild/android-arm@0.28.1':
- resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [android]
-
- '@esbuild/android-x64@0.28.1':
- resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [android]
-
- '@esbuild/darwin-arm64@0.28.1':
- resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [darwin]
-
- '@esbuild/darwin-x64@0.28.1':
- resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [darwin]
-
- '@esbuild/freebsd-arm64@0.28.1':
- resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [freebsd]
-
- '@esbuild/freebsd-x64@0.28.1':
- resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [freebsd]
-
- '@esbuild/linux-arm64@0.28.1':
- resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [linux]
-
- '@esbuild/linux-arm@0.28.1':
- resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [linux]
-
- '@esbuild/linux-ia32@0.28.1':
- resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [linux]
-
- '@esbuild/linux-loong64@0.28.1':
- resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
- engines: {node: '>=18'}
- cpu: [loong64]
- os: [linux]
-
- '@esbuild/linux-mips64el@0.28.1':
- resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
- engines: {node: '>=18'}
- cpu: [mips64el]
- os: [linux]
-
- '@esbuild/linux-ppc64@0.28.1':
- resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [linux]
-
- '@esbuild/linux-riscv64@0.28.1':
- resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
- engines: {node: '>=18'}
- cpu: [riscv64]
- os: [linux]
-
- '@esbuild/linux-s390x@0.28.1':
- resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
- engines: {node: '>=18'}
- cpu: [s390x]
- os: [linux]
-
- '@esbuild/linux-x64@0.28.1':
- resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [linux]
-
- '@esbuild/netbsd-arm64@0.28.1':
- resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [netbsd]
-
- '@esbuild/netbsd-x64@0.28.1':
- resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [netbsd]
-
- '@esbuild/openbsd-arm64@0.28.1':
- resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openbsd]
-
- '@esbuild/openbsd-x64@0.28.1':
- resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [openbsd]
-
- '@esbuild/openharmony-arm64@0.28.1':
- resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openharmony]
-
- '@esbuild/sunos-x64@0.28.1':
- resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [sunos]
-
- '@esbuild/win32-arm64@0.28.1':
- resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [win32]
-
- '@esbuild/win32-ia32@0.28.1':
- resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [win32]
-
- '@esbuild/win32-x64@0.28.1':
- resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [win32]
-
- '@penpot/plugin-styles@1.4.1':
- resolution: {integrity: sha512-6TuJqKQsq1Xmhn2A02R+kCOzIzIdqgFg5z6ncLH2PlAflKIX6aYsGiOF7yFx4RYgCegRVMFPnVis6/hwO+YGQg==}
-
- '@penpot/plugin-types@1.4.1':
- resolution: {integrity: sha512-pHE2B3GI8M5JR03S/NdBoN+z6e1R1IEh3vpFbLG9LN0EZpQE6nEbmCo5jWAWI73Jqlg6CHG/RWVJNmWECnkDTA==}
-
- '@rollup/rollup-android-arm-eabi@4.62.2':
- resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
- cpu: [arm]
- os: [android]
-
- '@rollup/rollup-android-arm64@4.62.2':
- resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==}
- cpu: [arm64]
- os: [android]
-
- '@rollup/rollup-darwin-arm64@4.62.2':
- resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
- cpu: [arm64]
- os: [darwin]
-
- '@rollup/rollup-darwin-x64@4.62.2':
- resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==}
- cpu: [x64]
- os: [darwin]
-
- '@rollup/rollup-freebsd-arm64@4.62.2':
- resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==}
- cpu: [arm64]
- os: [freebsd]
-
- '@rollup/rollup-freebsd-x64@4.62.2':
- resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==}
- cpu: [x64]
- os: [freebsd]
-
- '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
- resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
- cpu: [arm]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-arm-musleabihf@4.62.2':
- resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
- cpu: [arm]
- os: [linux]
- libc: [musl]
-
- '@rollup/rollup-linux-arm64-gnu@4.62.2':
- resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-arm64-musl@4.62.2':
- resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@rollup/rollup-linux-loong64-gnu@4.62.2':
- resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
- cpu: [loong64]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-loong64-musl@4.62.2':
- resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
- cpu: [loong64]
- os: [linux]
- libc: [musl]
-
- '@rollup/rollup-linux-ppc64-gnu@4.62.2':
- resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-ppc64-musl@4.62.2':
- resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
- cpu: [ppc64]
- os: [linux]
- libc: [musl]
-
- '@rollup/rollup-linux-riscv64-gnu@4.62.2':
- resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
- cpu: [riscv64]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-riscv64-musl@4.62.2':
- resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
- cpu: [riscv64]
- os: [linux]
- libc: [musl]
-
- '@rollup/rollup-linux-s390x-gnu@4.62.2':
- resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-x64-gnu@4.62.2':
- resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@rollup/rollup-linux-x64-musl@4.62.2':
- resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@rollup/rollup-openbsd-x64@4.62.2':
- resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
- cpu: [x64]
- os: [openbsd]
-
- '@rollup/rollup-openharmony-arm64@4.62.2':
- resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==}
- cpu: [arm64]
- os: [openharmony]
-
- '@rollup/rollup-win32-arm64-msvc@4.62.2':
- resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==}
- cpu: [arm64]
- os: [win32]
-
- '@rollup/rollup-win32-ia32-msvc@4.62.2':
- resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==}
- cpu: [ia32]
- os: [win32]
-
- '@rollup/rollup-win32-x64-gnu@4.62.2':
- resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==}
- cpu: [x64]
- os: [win32]
-
- '@rollup/rollup-win32-x64-msvc@4.62.2':
- resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==}
- cpu: [x64]
- os: [win32]
-
- '@types/ansi-html@0.0.0':
- resolution: {integrity: sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==}
-
- '@types/debug@4.1.13':
- resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
-
- '@types/estree@1.0.9':
- resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
-
- '@types/ms@2.1.0':
- resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
-
- '@types/node@26.0.1':
- resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
-
- '@types/ws@8.18.1':
- resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
-
- ansi-html@0.0.9:
- resolution: {integrity: sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==}
- engines: {'0': node >= 0.8.0}
- hasBin: true
-
- chalk@5.6.2:
- resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
- engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
-
- commander@12.1.0:
- resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
- engines: {node: '>=18'}
-
- debug@4.4.3:
- resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
- engines: {node: '>=6.0'}
- peerDependencies:
- supports-color: '*'
- peerDependenciesMeta:
- supports-color:
- optional: true
-
- esbuild@0.28.1:
- resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
- engines: {node: '>=18'}
- hasBin: true
-
- escape-goat@4.0.0:
- resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==}
- engines: {node: '>=12'}
-
- fdir@6.5.0:
- resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
- engines: {node: '>=12.0.0'}
- peerDependencies:
- picomatch: ^3 || ^4
- peerDependenciesMeta:
- picomatch:
- optional: true
-
- fsevents@2.3.2:
- resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
- os: [darwin]
-
- fsevents@2.3.3:
- resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
- os: [darwin]
-
- ms@2.1.3:
- resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
-
- nanoid@3.3.15:
- resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
- p-defer@4.0.1:
- resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==}
- engines: {node: '>=12'}
-
- picocolors@1.1.1:
- resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
-
- picomatch@4.0.4:
- resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
- engines: {node: '>=12'}
-
- playwright-core@1.61.1:
- resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
- engines: {node: '>=18'}
- hasBin: true
-
- playwright@1.61.1:
- resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
- engines: {node: '>=18'}
- hasBin: true
-
- postcss@8.5.16:
- resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
- engines: {node: ^10 || ^12 || >=14}
-
- prettier@3.9.4:
- resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==}
- engines: {node: '>=14'}
- hasBin: true
-
- rollup@4.62.2:
- resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
- engines: {node: '>=18.0.0', npm: '>=8.0.0'}
- hasBin: true
-
- source-map-js@1.2.1:
- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
- engines: {node: '>=0.10.0'}
-
- tinyglobby@0.2.17:
- resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
- engines: {node: '>=12.0.0'}
-
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
- engines: {node: '>=14.17'}
- hasBin: true
-
- undici-types@8.3.0:
- resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
-
- vite-live-preview@0.3.2:
- resolution: {integrity: sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==}
- hasBin: true
- peerDependencies:
- vite: '>=5.2.13'
-
- vite@7.3.6:
- resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- '@types/node': ^20.19.0 || >=22.12.0
- jiti: '>=1.21.0'
- less: ^4.0.0
- lightningcss: ^1.21.0
- sass: ^1.70.0
- sass-embedded: ^1.70.0
- stylus: '>=0.54.8'
- sugarss: ^5.0.0
- terser: ^5.16.0
- tsx: ^4.8.1
- yaml: ^2.4.2
- peerDependenciesMeta:
- '@types/node':
- optional: true
- jiti:
- optional: true
- less:
- optional: true
- lightningcss:
- optional: true
- sass:
- optional: true
- sass-embedded:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- tsx:
- optional: true
- yaml:
- optional: true
-
- ws@8.21.0:
- resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
- engines: {node: '>=10.0.0'}
- peerDependencies:
- bufferutil: ^4.0.1
- utf-8-validate: '>=5.0.2'
- peerDependenciesMeta:
- bufferutil:
- optional: true
- utf-8-validate:
- optional: true
-
-snapshots:
-
- '@commander-js/extra-typings@12.1.0(commander@12.1.0)':
- dependencies:
- commander: 12.1.0
-
- '@esbuild/aix-ppc64@0.28.1':
- optional: true
-
- '@esbuild/android-arm64@0.28.1':
- optional: true
-
- '@esbuild/android-arm@0.28.1':
- optional: true
-
- '@esbuild/android-x64@0.28.1':
- optional: true
-
- '@esbuild/darwin-arm64@0.28.1':
- optional: true
-
- '@esbuild/darwin-x64@0.28.1':
- optional: true
-
- '@esbuild/freebsd-arm64@0.28.1':
- optional: true
-
- '@esbuild/freebsd-x64@0.28.1':
- optional: true
-
- '@esbuild/linux-arm64@0.28.1':
- optional: true
-
- '@esbuild/linux-arm@0.28.1':
- optional: true
-
- '@esbuild/linux-ia32@0.28.1':
- optional: true
-
- '@esbuild/linux-loong64@0.28.1':
- optional: true
-
- '@esbuild/linux-mips64el@0.28.1':
- optional: true
-
- '@esbuild/linux-ppc64@0.28.1':
- optional: true
-
- '@esbuild/linux-riscv64@0.28.1':
- optional: true
-
- '@esbuild/linux-s390x@0.28.1':
- optional: true
-
- '@esbuild/linux-x64@0.28.1':
- optional: true
-
- '@esbuild/netbsd-arm64@0.28.1':
- optional: true
-
- '@esbuild/netbsd-x64@0.28.1':
- optional: true
-
- '@esbuild/openbsd-arm64@0.28.1':
- optional: true
-
- '@esbuild/openbsd-x64@0.28.1':
- optional: true
-
- '@esbuild/openharmony-arm64@0.28.1':
- optional: true
-
- '@esbuild/sunos-x64@0.28.1':
- optional: true
-
- '@esbuild/win32-arm64@0.28.1':
- optional: true
-
- '@esbuild/win32-ia32@0.28.1':
- optional: true
-
- '@esbuild/win32-x64@0.28.1':
- optional: true
-
- '@penpot/plugin-styles@1.4.1': {}
-
- '@penpot/plugin-types@1.4.1': {}
-
- '@rollup/rollup-android-arm-eabi@4.62.2':
- optional: true
-
- '@rollup/rollup-android-arm64@4.62.2':
- optional: true
-
- '@rollup/rollup-darwin-arm64@4.62.2':
- optional: true
-
- '@rollup/rollup-darwin-x64@4.62.2':
- optional: true
-
- '@rollup/rollup-freebsd-arm64@4.62.2':
- optional: true
-
- '@rollup/rollup-freebsd-x64@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-arm-musleabihf@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-arm64-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-arm64-musl@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-loong64-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-loong64-musl@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-ppc64-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-ppc64-musl@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-riscv64-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-riscv64-musl@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-s390x-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-x64-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-linux-x64-musl@4.62.2':
- optional: true
-
- '@rollup/rollup-openbsd-x64@4.62.2':
- optional: true
-
- '@rollup/rollup-openharmony-arm64@4.62.2':
- optional: true
-
- '@rollup/rollup-win32-arm64-msvc@4.62.2':
- optional: true
-
- '@rollup/rollup-win32-ia32-msvc@4.62.2':
- optional: true
-
- '@rollup/rollup-win32-x64-gnu@4.62.2':
- optional: true
-
- '@rollup/rollup-win32-x64-msvc@4.62.2':
- optional: true
-
- '@types/ansi-html@0.0.0': {}
-
- '@types/debug@4.1.13':
- dependencies:
- '@types/ms': 2.1.0
-
- '@types/estree@1.0.9': {}
-
- '@types/ms@2.1.0': {}
-
- '@types/node@26.0.1':
- dependencies:
- undici-types: 8.3.0
-
- '@types/ws@8.18.1':
- dependencies:
- '@types/node': 26.0.1
-
- ansi-html@0.0.9: {}
-
- chalk@5.6.2: {}
-
- commander@12.1.0: {}
-
- debug@4.4.3:
- dependencies:
- ms: 2.1.3
-
- esbuild@0.28.1:
- optionalDependencies:
- '@esbuild/aix-ppc64': 0.28.1
- '@esbuild/android-arm': 0.28.1
- '@esbuild/android-arm64': 0.28.1
- '@esbuild/android-x64': 0.28.1
- '@esbuild/darwin-arm64': 0.28.1
- '@esbuild/darwin-x64': 0.28.1
- '@esbuild/freebsd-arm64': 0.28.1
- '@esbuild/freebsd-x64': 0.28.1
- '@esbuild/linux-arm': 0.28.1
- '@esbuild/linux-arm64': 0.28.1
- '@esbuild/linux-ia32': 0.28.1
- '@esbuild/linux-loong64': 0.28.1
- '@esbuild/linux-mips64el': 0.28.1
- '@esbuild/linux-ppc64': 0.28.1
- '@esbuild/linux-riscv64': 0.28.1
- '@esbuild/linux-s390x': 0.28.1
- '@esbuild/linux-x64': 0.28.1
- '@esbuild/netbsd-arm64': 0.28.1
- '@esbuild/netbsd-x64': 0.28.1
- '@esbuild/openbsd-arm64': 0.28.1
- '@esbuild/openbsd-x64': 0.28.1
- '@esbuild/openharmony-arm64': 0.28.1
- '@esbuild/sunos-x64': 0.28.1
- '@esbuild/win32-arm64': 0.28.1
- '@esbuild/win32-ia32': 0.28.1
- '@esbuild/win32-x64': 0.28.1
-
- escape-goat@4.0.0: {}
-
- fdir@6.5.0(picomatch@4.0.4):
- optionalDependencies:
- picomatch: 4.0.4
-
- fsevents@2.3.2:
- optional: true
-
- fsevents@2.3.3:
- optional: true
-
- ms@2.1.3: {}
-
- nanoid@3.3.15: {}
-
- p-defer@4.0.1: {}
-
- picocolors@1.1.1: {}
-
- picomatch@4.0.4: {}
-
- playwright-core@1.61.1: {}
-
- playwright@1.61.1:
- dependencies:
- playwright-core: 1.61.1
- optionalDependencies:
- fsevents: 2.3.2
-
- postcss@8.5.16:
- dependencies:
- nanoid: 3.3.15
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
- prettier@3.9.4: {}
-
- rollup@4.62.2:
- dependencies:
- '@types/estree': 1.0.9
- optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.62.2
- '@rollup/rollup-android-arm64': 4.62.2
- '@rollup/rollup-darwin-arm64': 4.62.2
- '@rollup/rollup-darwin-x64': 4.62.2
- '@rollup/rollup-freebsd-arm64': 4.62.2
- '@rollup/rollup-freebsd-x64': 4.62.2
- '@rollup/rollup-linux-arm-gnueabihf': 4.62.2
- '@rollup/rollup-linux-arm-musleabihf': 4.62.2
- '@rollup/rollup-linux-arm64-gnu': 4.62.2
- '@rollup/rollup-linux-arm64-musl': 4.62.2
- '@rollup/rollup-linux-loong64-gnu': 4.62.2
- '@rollup/rollup-linux-loong64-musl': 4.62.2
- '@rollup/rollup-linux-ppc64-gnu': 4.62.2
- '@rollup/rollup-linux-ppc64-musl': 4.62.2
- '@rollup/rollup-linux-riscv64-gnu': 4.62.2
- '@rollup/rollup-linux-riscv64-musl': 4.62.2
- '@rollup/rollup-linux-s390x-gnu': 4.62.2
- '@rollup/rollup-linux-x64-gnu': 4.62.2
- '@rollup/rollup-linux-x64-musl': 4.62.2
- '@rollup/rollup-openbsd-x64': 4.62.2
- '@rollup/rollup-openharmony-arm64': 4.62.2
- '@rollup/rollup-win32-arm64-msvc': 4.62.2
- '@rollup/rollup-win32-ia32-msvc': 4.62.2
- '@rollup/rollup-win32-x64-gnu': 4.62.2
- '@rollup/rollup-win32-x64-msvc': 4.62.2
- fsevents: 2.3.3
-
- source-map-js@1.2.1: {}
-
- tinyglobby@0.2.17:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
-
- typescript@5.9.3: {}
-
- undici-types@8.3.0: {}
-
- vite-live-preview@0.3.2(vite@7.3.6(@types/node@26.0.1)):
- dependencies:
- '@commander-js/extra-typings': 12.1.0(commander@12.1.0)
- '@types/ansi-html': 0.0.0
- '@types/debug': 4.1.13
- '@types/ws': 8.18.1
- ansi-html: 0.0.9
- chalk: 5.6.2
- commander: 12.1.0
- debug: 4.4.3
- escape-goat: 4.0.0
- p-defer: 4.0.1
- vite: 7.3.6(@types/node@26.0.1)
- ws: 8.21.0
- transitivePeerDependencies:
- - bufferutil
- - supports-color
- - utf-8-validate
-
- vite@7.3.6(@types/node@26.0.1):
- dependencies:
- esbuild: 0.28.1
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
- postcss: 8.5.16
- rollup: 4.62.2
- tinyglobby: 0.2.17
- optionalDependencies:
- '@types/node': 26.0.1
- fsevents: 2.3.3
-
- ws@8.21.0: {}
diff --git a/plugins/apps/composable-test-suite/pnpm-workspace.yaml b/plugins/apps/composable-test-suite/pnpm-workspace.yaml
deleted file mode 100644
index c5739b7433..0000000000
--- a/plugins/apps/composable-test-suite/pnpm-workspace.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-ignoredBuiltDependencies:
- - esbuild
diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json
index 5e6772cc8e..6036de7b96 100644
--- a/plugins/apps/contrast-plugin/package.json
+++ b/plugins/apps/contrast-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/contrast-plugin/vite.config.ts b/plugins/apps/contrast-plugin/vite.config.ts
index 5b4538a2e6..afa64b0c84 100644
--- a/plugins/apps/contrast-plugin/vite.config.ts
+++ b/plugins/apps/contrast-plugin/vite.config.ts
@@ -2,7 +2,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
test: {
globals: true,
environment: 'jsdom',
diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json
index 9507e644df..ee5d641bf9 100644
--- a/plugins/apps/create-palette-plugin/package.json
+++ b/plugins/apps/create-palette-plugin/package.json
@@ -12,5 +12,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/create-palette-plugin/vite.config.ts b/plugins/apps/create-palette-plugin/vite.config.ts
index f8b241a643..b82adfd3c6 100644
--- a/plugins/apps/create-palette-plugin/vite.config.ts
+++ b/plugins/apps/create-palette-plugin/vite.config.ts
@@ -1,7 +1,7 @@
///
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
server: {
port: 4202,
host: '0.0.0.0',
diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json
index 008fd417ff..866566cfa8 100644
--- a/plugins/apps/e2e/package.json
+++ b/plugins/apps/e2e/package.json
@@ -7,5 +7,5 @@
"test": "vitest",
"lint": "eslint ."
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/e2e/vite.config.ts b/plugins/apps/e2e/vite.config.ts
index ae60487c66..12a797acb0 100644
--- a/plugins/apps/e2e/vite.config.ts
+++ b/plugins/apps/e2e/vite.config.ts
@@ -2,7 +2,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
test: {
testTimeout: 20000,
watch: false,
diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json
index 59850da654..3e919d958b 100644
--- a/plugins/apps/example-styles/package.json
+++ b/plugins/apps/example-styles/package.json
@@ -11,5 +11,5 @@
"serve": "vite preview",
"lint": "eslint ."
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/example-styles/vite.config.ts b/plugins/apps/example-styles/vite.config.ts
index 39a4e556da..43d5819170 100644
--- a/plugins/apps/example-styles/vite.config.ts
+++ b/plugins/apps/example-styles/vite.config.ts
@@ -1,7 +1,7 @@
///
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
server: {
port: 4202,
host: '0.0.0.0',
diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json
index 5df19f2d44..749eebfdff 100644
--- a/plugins/apps/icons-plugin/package.json
+++ b/plugins/apps/icons-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json
index f747085348..fffd54a5d3 100644
--- a/plugins/apps/lorem-ipsum-plugin/package.json
+++ b/plugins/apps/lorem-ipsum-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/lorem-ipsum-plugin/vite.config.ts b/plugins/apps/lorem-ipsum-plugin/vite.config.ts
index 92c2628f69..a26b77cba6 100644
--- a/plugins/apps/lorem-ipsum-plugin/vite.config.ts
+++ b/plugins/apps/lorem-ipsum-plugin/vite.config.ts
@@ -2,7 +2,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
test: {
watch: false,
globals: true,
diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json
index e88b6354fe..3d2fa7ec9b 100644
--- a/plugins/apps/plugin-api-test-suite/package.json
+++ b/plugins/apps/plugin-api-test-suite/package.json
@@ -19,5 +19,5 @@
"devDependencies": {
"playwright": "^1.62.1"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/plugin-api-test-suite/tsconfig.node.json b/plugins/apps/plugin-api-test-suite/tsconfig.node.json
index 15543271a6..0314a479cf 100644
--- a/plugins/apps/plugin-api-test-suite/tsconfig.node.json
+++ b/plugins/apps/plugin-api-test-suite/tsconfig.node.json
@@ -4,6 +4,7 @@
"outDir": "../../dist/out-tsc",
"module": "ESNext",
"moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
"types": ["node"],
"noUnusedLocals": true,
"noUnusedParameters": true
diff --git a/plugins/apps/plugin-api-test-suite/vite.config.headless.ts b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts
index c2fad314f5..706f1f43d5 100644
--- a/plugins/apps/plugin-api-test-suite/vite.config.headless.ts
+++ b/plugins/apps/plugin-api-test-suite/vite.config.headless.ts
@@ -1,4 +1,4 @@
-import { iifeConfig } from './vite.config.iife';
+import { iifeConfig } from './vite.config.iife.ts';
// Builds the CI test entry as a single self-executing (IIFE) bundle, evaluated
// inside the Penpot plugin sandbox via `globalThis.ɵloadPlugin({ code })` by the
diff --git a/plugins/apps/plugin-api-test-suite/vite.config.iife.ts b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts
index b2ee8a2985..130db9dd37 100644
--- a/plugins/apps/plugin-api-test-suite/vite.config.iife.ts
+++ b/plugins/apps/plugin-api-test-suite/vite.config.iife.ts
@@ -12,7 +12,7 @@ import { defineConfig, type UserConfig } from 'vite';
*/
export function iifeConfig(name: string, entry: string): UserConfig {
return defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
resolve: {
tsconfigPaths: true,
},
diff --git a/plugins/apps/plugin-api-test-suite/vite.config.tests.ts b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts
index 39468d5209..dbf4d20114 100644
--- a/plugins/apps/plugin-api-test-suite/vite.config.tests.ts
+++ b/plugins/apps/plugin-api-test-suite/vite.config.tests.ts
@@ -1,4 +1,4 @@
-import { iifeConfig } from './vite.config.iife';
+import { iifeConfig } from './vite.config.iife.ts';
// Builds the test cases as a single self-executing (IIFE) bundle that publishes
// the discovered tests on `globalThis.__penpotReloadedTests`. The UI "Reload"
diff --git a/plugins/apps/plugin-api-test-suite/vite.config.ts b/plugins/apps/plugin-api-test-suite/vite.config.ts
index 25c811f49e..00e01630ff 100644
--- a/plugins/apps/plugin-api-test-suite/vite.config.ts
+++ b/plugins/apps/plugin-api-test-suite/vite.config.ts
@@ -2,7 +2,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
// Emit relative asset URLs in index.html so the built plugin works when served
// from a subdirectory (Penpot serves the bundled plugins under `/plugins/...`).
// Vite resolves `./` to `/` for the dev server, so `pnpm run dev` is unaffected.
diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json
index c2db3b3009..7a79003310 100644
--- a/plugins/apps/poc-state-plugin/package.json
+++ b/plugins/apps/poc-state-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json
index e675d5c538..852e7f1764 100644
--- a/plugins/apps/poc-tokens-plugin/package.json
+++ b/plugins/apps/poc-tokens-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "exit 0"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/poc-tokens-plugin/vite.config.ts b/plugins/apps/poc-tokens-plugin/vite.config.ts
index 80619b371f..0f62223ab9 100644
--- a/plugins/apps/poc-tokens-plugin/vite.config.ts
+++ b/plugins/apps/poc-tokens-plugin/vite.config.ts
@@ -2,7 +2,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
test: {
watch: false,
globals: true,
diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json
index ca57c4ff7c..87279ccc4b 100644
--- a/plugins/apps/rename-layers-plugin/package.json
+++ b/plugins/apps/rename-layers-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json
index ccdb6df697..20bfb2245f 100644
--- a/plugins/apps/table-plugin/package.json
+++ b/plugins/apps/table-plugin/package.json
@@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json
index 15935921fe..c63c7069ed 100644
--- a/plugins/libs/plugin-types/package.json
+++ b/plugins/libs/plugin-types/package.json
@@ -7,5 +7,5 @@
"build": "node ../../tools/scripts/build-types.mjs",
"lint": "tsc -p . --noEmit"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json
index fa31aac91a..4dc44175bb 100644
--- a/plugins/libs/plugins-runtime/package.json
+++ b/plugins/libs/plugins-runtime/package.json
@@ -16,5 +16,5 @@
"lint": "eslint .",
"test": "vitest"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts
new file mode 100644
index 0000000000..52a0267db0
--- /dev/null
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-context.spec.ts
@@ -0,0 +1,82 @@
+import { describe, it, vi, expect, beforeEach } from 'vitest';
+import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
+import { createPlugin } from './create-plugin';
+import { ses } from './ses.js';
+import type { Context } from '@penpot/plugin-types';
+import type { Manifest } from './models/manifest.model.js';
+
+vi.mock('./create-plugin', () => ({
+ createPlugin: vi.fn(),
+}));
+
+// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
+// real `ses.harden` to assert that `loadPlugin` never hardens the host
+// context.
+
+describe('loadPlugin host context boundary (regression for #11001)', () => {
+ let manifest: Manifest;
+
+ beforeEach(() => {
+ manifest = {
+ pluginId: 'test-plugin',
+ name: 'Test Plugin',
+ host: '',
+ code: '',
+ permissions: ['content:read'],
+ };
+
+ vi.mocked(createPlugin).mockResolvedValue({
+ plugin: {
+ close: vi.fn(),
+ sendMessage: vi.fn(),
+ },
+ } as unknown as Awaited>);
+ });
+
+ it('does not freeze host-owned functions reachable through the context', async () => {
+ const hostListener = function hostListener() {
+ return 'host-value';
+ };
+ const nestedHostObject = {
+ nestedFn() {
+ return 'nested';
+ },
+ };
+ const hostContext = {
+ addListener: hostListener,
+ nested: nestedHostObject,
+ } as unknown as Context;
+
+ setContextBuilder(() => hostContext);
+
+ const hardenSpy = vi.spyOn(ses, 'harden');
+
+ await loadPlugin(manifest);
+
+ // The host context itself must be passed through untouched so the host
+ // can keep modifying its own runtime objects (e.g. on page navigation).
+ expect(createPlugin).toHaveBeenCalledWith(
+ hostContext,
+ manifest,
+ expect.any(Function),
+ undefined,
+ );
+
+ // Host-owned functions must remain extensible: page navigation and
+ // runtime code may patch/augment them (e.g. assigning `toString` on a
+ // wrapped listener). A deep `ses.harden(context)` here would freeze
+ // them and turn such later assignments into
+ // `TypeError: Cannot assign to read only property 'toString'`.
+ expect(Object.isFrozen(hostListener)).toBe(false);
+ expect(Object.isExtensible(hostListener)).toBe(true);
+ expect(Object.isFrozen(nestedHostObject)).toBe(false);
+ expect(() => {
+ hostListener.toString = () => 'patched-by-host';
+ }).not.toThrow();
+
+ expect(hardenSpy).not.toHaveBeenCalled();
+ expect(getPlugins()).toHaveLength(1);
+
+ hardenSpy.mockRestore();
+ });
+});
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts
new file mode 100644
index 0000000000..06ef7b20b7
--- /dev/null
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts
@@ -0,0 +1,169 @@
+import { describe, it, vi, expect, beforeAll } from 'vitest';
+import 'ses';
+import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
+import type { Context } from '@penpot/plugin-types';
+import type { Manifest } from './models/manifest.model.js';
+
+// Real initialization-path regression tests for #11001.
+//
+// NOTE: `./create-plugin`, `./plugin-manager` and
+// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
+// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
+// path with the real SES implementation, mirroring the production
+// initialization order from `plugins-runtime/src/index.ts`:
+// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
+//
+// `hardenIntrinsics()` is deliberately NOT called up front: the first test
+// must run in the production window where only `repairIntrinsics` has run.
+// Tests run in declaration order; the later tests build on the locked-down
+// state the first real `loadPlugin` leaves behind (via `createSandbox`).
+//
+// Note on SES isolation: this suite depends on Vitest's default
+// file-level isolation. Each test file runs in a separate worker
+// process, so SES intrinsics frozen here do not leak into other
+// spec files.
+
+const REPAIR_OPTIONS = {
+ evalTaming: 'unsafeEval',
+ stackFiltering: 'verbose',
+ errorTaming: 'unsafe',
+ consoleTaming: 'unsafe',
+ errorTrapping: 'none',
+ unhandledRejectionTrapping: 'none',
+};
+
+function makeManifest(
+ code: string,
+ permissions: Manifest['permissions'],
+): Manifest {
+ return {
+ pluginId: 'test-plugin',
+ name: 'Test Plugin',
+ host: '',
+ code,
+ permissions,
+ };
+}
+
+function makeHostFixture() {
+ const listenerTypes: string[] = [];
+ const listeners = new Map();
+ // Inline code (empty host + non-URL code) resolves without network, so no
+ // fetch mock is needed. UI/modal APIs are never touched by the probe code.
+ const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
+ const selection: object[] = [{ id: 'shape-1' }];
+ const context = {
+ addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
+ const id = Symbol(type);
+ listeners.set(id, type);
+ listenerTypes.push(type);
+ return id;
+ },
+ removeListener: (id: symbol) => {
+ listeners.delete(id);
+ },
+ theme: 'dark',
+ createRectangle,
+ selection,
+ // Host-only member: present on the raw context but NOT part of the
+ // public penpot API. Plugin code must never see it (see B-2 below).
+ __internalSecret: 'host-internal',
+ } as unknown as Context;
+ return { context, listenerTypes, createRectangle, selection };
+}
+
+function lastCompartmentGlobalThis(): Record {
+ const plugins = getPlugins();
+ const last = plugins[plugins.length - 1] as unknown as {
+ compartment: { compartment: { globalThis: Record } };
+ };
+ return last.compartment.compartment.globalThis;
+}
+
+describe('loadPlugin real initialization path (regression for #11001)', () => {
+ beforeAll(() => {
+ // Production module-load step only: repairs intrinsics WITHOUT
+ // installing override taming, exactly like `index.ts` at import time.
+ (
+ globalThis as unknown as { repairIntrinsics(opts: object): void }
+ ).repairIntrinsics({ ...REPAIR_OPTIONS });
+ });
+
+ it('loads through the real path and keeps host function augmentation working', async () => {
+ const fixture = makeHostFixture();
+ setContextBuilder(() => fixture.context);
+
+ await loadPlugin(
+ makeManifest('penpot.on("finish", function () {});', ['content:read']),
+ );
+
+ // The plugin code really ran inside the sandbox: the manager registers
+ // `themechange` + `finish`, and the plugin code adds its own `finish`
+ // listener through the public API.
+ expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
+ expect(getPlugins()).toHaveLength(1);
+
+ // The user-facing behavior from #11001: host-side augmentation of a
+ // fresh function (e.g. assigning `toString` during page navigation)
+ // succeeds after a real plugin load.
+ const freshWrapper = function freshWrapper() {
+ return 'navigation-wrapper';
+ };
+ expect(() => {
+ freshWrapper.toString = () => 'patched-by-runtime';
+ }).not.toThrow();
+ expect(fixture.listenerTypes.length).toBe(3);
+ });
+
+ it('denies the write API without permission and leaves the host untouched', async () => {
+ const fixture = makeHostFixture();
+ setContextBuilder(() => fixture.context);
+
+ await expect(
+ loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
+ ).rejects.toThrow(/content:write/);
+ expect(fixture.createRectangle).not.toHaveBeenCalled();
+ });
+
+ it('allows the same write API with permission', async () => {
+ const fixture = makeHostFixture();
+ setContextBuilder(() => fixture.context);
+
+ await loadPlugin(
+ makeManifest('penpot.createRectangle();', [
+ 'content:read',
+ 'content:write',
+ ]),
+ );
+ expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not expose raw host-only context members to plugin code', async () => {
+ const fixture = makeHostFixture();
+ setContextBuilder(() => fixture.context);
+
+ await loadPlugin(
+ makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
+ 'content:read',
+ ]),
+ );
+ // The public `penpot` object is a boundary proxy over a curated API, not
+ // the raw host context, so host-only members are invisible inside.
+ expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
+ });
+
+ it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
+ const fixture = makeHostFixture();
+ setContextBuilder(() => fixture.context);
+
+ await loadPlugin(
+ makeManifest(
+ 'penpot.createRectangle(); ' +
+ 'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
+ ['content:read', 'content:write'],
+ ),
+ );
+ expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
+ expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
+ });
+});
diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts
index d05178f1f7..dfd44f4971 100644
--- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts
+++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts
@@ -3,7 +3,6 @@ import type { Context } from '@penpot/plugin-types';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
-import { ses } from './ses.js';
let plugins: Awaited>[] = [];
@@ -54,8 +53,23 @@ export const loadPlugin = async function (
closeAllPlugins();
+ // The host context is not deeply frozen at this load stage.
+ //
+ // The context still contains host-internal function objects and shared
+ // prototypes that the host may legitimately extend after plugin load
+ // (for example, by assigning custom properties). Deep-freezing here
+ // would freeze those prototypes before SES override taming completes,
+ // preventing later host-side mutations with a "Cannot assign to read
+ // only property" TypeError.
+ //
+ // Responsibility boundary: this function forwards the context to the
+ // sandbox layer without deep-freezing it. The public API that plugins
+ // consume is constructed by the API module (`api/index.ts`), and
+ // `createSandbox`'s proxy handler applies `ses.safeReturn` to values
+ // crossing into the sandbox. Compartment isolation and intrinsics
+ // hardening are performed by createSandbox, not here.
const plugin = await createPlugin(
- ses.harden(context) as Context,
+ context,
manifest,
() => {
plugins = plugins.filter((api) => api !== plugin);
diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts
index d53f4f5296..7b4d8ac9cb 100644
--- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts
+++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts
@@ -3,6 +3,7 @@ import { createPluginManager } from './plugin-manager';
import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
+import { validateUIUrl } from './validate-url.js';
import type { Context, Theme } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
@@ -16,6 +17,10 @@ vi.mock('./api/openUI.api.js', () => ({
openUIApi: vi.fn(),
}));
+vi.mock('./validate-url.js', () => ({
+ validateUIUrl: vi.fn(),
+}));
+
describe('createPluginManager', () => {
let mockContext: Context;
let manifest: Manifest;
@@ -294,4 +299,39 @@ describe('createPluginManager', () => {
expect(mockContext.removeListener).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
+
+ it('should validate the modal URL before opening', async () => {
+ const pluginManager = await createPluginManager(
+ mockContext,
+ manifest,
+ onCloseCallback,
+ onReloadModal,
+ );
+
+ pluginManager.openModal('Test Modal', '/test-url');
+
+ expect(validateUIUrl).toHaveBeenCalledWith(
+ 'https://example.com/plugin',
+ manifest.host,
+ );
+ });
+
+ it('should throw when URL validation fails', async () => {
+ vi.mocked(validateUIUrl).mockImplementation(() => {
+ throw new Error("Plugin UI URL must not point to Penpot's own domain");
+ });
+
+ const pluginManager = await createPluginManager(
+ mockContext,
+ manifest,
+ onCloseCallback,
+ onReloadModal,
+ );
+
+ expect(() => pluginManager.openModal('Test Modal', '/test-url')).toThrow(
+ "Plugin UI URL must not point to Penpot's own domain",
+ );
+
+ expect(openUIApi).not.toHaveBeenCalled();
+ });
});
diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts
index 2a2b43e1c6..7878a1abcd 100644
--- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts
+++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts
@@ -7,6 +7,7 @@ import { openUIApi } from './api/openUI.api.js';
import { OpenUIOptions } from './models/open-ui-options.model.js';
import { RegisterListener } from './models/plugin.model.js';
import { openUISchema } from './models/open-ui-options.schema.js';
+import { validateUIUrl } from './validate-url.js';
export async function createPluginManager(
context: Context,
@@ -94,6 +95,7 @@ export async function createPluginManager(
const openModal = (name: string, url: string, options?: OpenUIOptions) => {
const theme = context.theme as Theme;
const modalUrl = prepareUrl(manifest, url, { theme });
+ validateUIUrl(modalUrl, manifest.host);
if (modal?.getAttribute('iframe-src') === modalUrl) {
return;
diff --git a/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts b/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts
new file mode 100644
index 0000000000..34466a69b6
--- /dev/null
+++ b/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ getPenpotOrigin,
+ isPenpotOrigin,
+ validateUIUrl,
+} from './validate-url.js';
+
+describe('validate-url', () => {
+ const originalLocation = globalThis.location;
+ const originalPenpotPublicURI = (globalThis as any).penpotPublicURI;
+ const externalHost = 'https://example.com';
+
+ beforeEach(() => {
+ delete (globalThis as any).penpotPublicURI;
+ });
+
+ afterEach(() => {
+ if (originalPenpotPublicURI !== undefined) {
+ (globalThis as any).penpotPublicURI = originalPenpotPublicURI;
+ } else {
+ delete (globalThis as any).penpotPublicURI;
+ }
+ });
+
+ describe('getPenpotOrigin', () => {
+ it('should return location.origin when penpotPublicURI is not set', () => {
+ expect(getPenpotOrigin()).toBe(originalLocation.origin);
+ });
+
+ it('should return origin from penpotPublicURI when set', () => {
+ (globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
+ expect(getPenpotOrigin()).toBe('https://design.penpot.com');
+ });
+
+ it('should fall back to location.origin when penpotPublicURI is invalid', () => {
+ (globalThis as any).penpotPublicURI = 'not-a-valid-url';
+ expect(getPenpotOrigin()).toBe(originalLocation.origin);
+ });
+ });
+
+ describe('isPenpotOrigin', () => {
+ it('should be true for a URL on Penpot origin', () => {
+ expect(
+ isPenpotOrigin(`${originalLocation.origin}/plugin/manifest.json`),
+ ).toBe(true);
+ });
+
+ it('should be false for a URL on another origin', () => {
+ expect(isPenpotOrigin(`${externalHost}/manifest.json`)).toBe(false);
+ });
+
+ it('should be false for an unparseable URL', () => {
+ expect(isPenpotOrigin('not-a-valid-url')).toBe(false);
+ });
+ });
+
+ describe('validateUIUrl', () => {
+ it('should throw when URL has same origin as location.origin', () => {
+ const penpotOrigin = originalLocation.origin;
+ expect(() =>
+ validateUIUrl(`${penpotOrigin}/some/path`, externalHost),
+ ).toThrow("Plugin UI URL must not point to Penpot's own domain");
+ });
+
+ it('should not throw when URL has different origin', () => {
+ expect(() =>
+ validateUIUrl('https://example.com/plugin-ui', externalHost),
+ ).not.toThrow();
+ });
+
+ it('should throw when URL matches penpotPublicURI origin', () => {
+ (globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
+ expect(() =>
+ validateUIUrl('https://design.penpot.com/some/path', externalHost),
+ ).toThrow("Plugin UI URL must not point to Penpot's own domain");
+ });
+
+ it('should not throw when URL has same hostname but different port', () => {
+ const url = new URL(originalLocation.origin);
+ const differentPort = `${url.protocol}//${url.hostname}:9999`;
+ expect(() =>
+ validateUIUrl(`${differentPort}/path`, externalHost),
+ ).not.toThrow();
+ });
+
+ it('should throw even when URL has different path on same origin', () => {
+ const penpotOrigin = originalLocation.origin;
+ expect(() =>
+ validateUIUrl(`${penpotOrigin}/deeply/nested/path`, externalHost),
+ ).toThrow();
+ });
+
+ it('should not throw when the manifest is served from Penpot origin', () => {
+ const penpotOrigin = originalLocation.origin;
+ expect(() =>
+ validateUIUrl(`${penpotOrigin}/some/path`, `${penpotOrigin}/plugin`),
+ ).not.toThrow();
+ });
+
+ it('should not throw when the manifest is served from penpotPublicURI origin', () => {
+ (globalThis as any).penpotPublicURI = 'https://design.penpot.com/';
+ expect(() =>
+ validateUIUrl(
+ 'https://design.penpot.com/some/path',
+ 'https://design.penpot.com/plugin',
+ ),
+ ).not.toThrow();
+ });
+
+ it('should still throw when the manifest host is unparseable', () => {
+ const penpotOrigin = originalLocation.origin;
+ expect(() => validateUIUrl(`${penpotOrigin}/path`, '')).toThrow(
+ "Plugin UI URL must not point to Penpot's own domain",
+ );
+ });
+ });
+});
diff --git a/plugins/libs/plugins-runtime/src/lib/validate-url.ts b/plugins/libs/plugins-runtime/src/lib/validate-url.ts
new file mode 100644
index 0000000000..6ff4a72273
--- /dev/null
+++ b/plugins/libs/plugins-runtime/src/lib/validate-url.ts
@@ -0,0 +1,44 @@
+export function getPenpotOrigin(): string {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const publicUri = (globalThis as any).penpotPublicURI;
+ if (publicUri) {
+ try {
+ return new URL(publicUri).origin;
+ } catch {
+ // fall through to location.origin
+ }
+ }
+ return globalThis.location.origin;
+}
+
+/**
+ * Whether the given URL is served from Penpot's own origin. Unparseable URLs
+ * are considered external.
+ */
+export function isPenpotOrigin(url: string): boolean {
+ try {
+ return new URL(url).origin === getPenpotOrigin();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Rejects UI URLs that resolve to Penpot's own origin, which would let the
+ * plugin iframe escape its sandbox isolation.
+ *
+ * Plugins whose manifest is itself served from Penpot's origin are part of the
+ * instance and are exempt from the check.
+ */
+export function validateUIUrl(url: string, manifestHost: string): void {
+ if (isPenpotOrigin(manifestHost)) {
+ return;
+ }
+
+ const parsed = new URL(url);
+ if (parsed.origin === getPenpotOrigin()) {
+ throw new Error(
+ `Plugin UI URL must not point to Penpot's own domain: ${url}`,
+ );
+ }
+}
diff --git a/plugins/libs/plugins-runtime/vite.config.ts b/plugins/libs/plugins-runtime/vite.config.ts
index 14f8d61d18..a054f46cb1 100644
--- a/plugins/libs/plugins-runtime/vite.config.ts
+++ b/plugins/libs/plugins-runtime/vite.config.ts
@@ -7,7 +7,7 @@ import * as path from 'path';
import checker from 'vite-plugin-checker';
export default defineConfig({
- root: __dirname,
+ root: import.meta.dirname,
cacheDir: '../node_modules/.vite/plugins-runtime',
resolve: {
@@ -17,7 +17,7 @@ export default defineConfig({
plugins: [
dts({
entryRoot: 'src',
- tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
+ tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
}),
checker({
typescript: {
diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json
index 31e7d851b3..62f2b3c21b 100644
--- a/plugins/libs/plugins-styles/package.json
+++ b/plugins/libs/plugins-styles/package.json
@@ -6,5 +6,5 @@
"build": "node ../../tools/scripts/build-css.mjs",
"lint": "echo 0"
},
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
}
diff --git a/plugins/package.json b/plugins/package.json
index b7ab60fbd0..25fa71a7fb 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,7 +3,7 @@
"version": "0.6.0",
"type": "module",
"license": "MIT",
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"scripts": {
"start": "pnpm run start:app:runtime",
"start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"",
diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml
index d1d02b8eeb..70b95a2f81 100644
--- a/plugins/pnpm-lock.yaml
+++ b/plugins/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/plugins/pnpm-workspace.yaml b/plugins/pnpm-workspace.yaml
index a0099791fc..864c77bb38 100644
--- a/plugins/pnpm-workspace.yaml
+++ b/plugins/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
packages:
- 'apps/**'
- 'libs/**'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 008bd049b7..c35d255221 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,3 +1,104 @@
+---
+lockfileVersion: '9.0'
+
+importers:
+
+ .:
+ configDependencies: {}
+ packageManagerDependencies:
+ pnpm:
+ specifier: 12.3.4
+ version: 12.3.4
+
+packages:
+
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
+ cpu: [x64]
+ os: [win32]
+
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
+ engines: {node: '>=18.*'}
+ hasBin: true
+
+snapshots:
+
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.darwin-x64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ optional: true
+
+ '@pnpm/exe.linux-x64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.win32-arm64@12.3.4':
+ optional: true
+
+ '@pnpm/exe.win32-x64@12.3.4':
+ optional: true
+
+ pnpm@12.3.4:
+ optionalDependencies:
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
+
+---
lockfileVersion: '9.0'
settings:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 7a28fcc29c..612f46bca4 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: .pnpm-store
+
allowBuilds:
esbuild: true
diff --git a/render-wasm/package.json b/render-wasm/package.json
index cdbe651e3d..3f8b581f51 100644
--- a/render-wasm/package.json
+++ b/render-wasm/package.json
@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml
index c06712b52e..58127cd45a 100644
--- a/render-wasm/pnpm-lock.yaml
+++ b/render-wasm/pnpm-lock.yaml
@@ -7,96 +7,96 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
- specifier: 12.0.0
- version: 12.0.0
+ specifier: 12.3.4
+ version: 12.3.4
packages:
- '@pnpm/exe.darwin-arm64@12.0.0':
- resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ '@pnpm/exe.darwin-arm64@12.3.4':
+ resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
cpu: [arm64]
os: [darwin]
- '@pnpm/exe.darwin-x64@12.0.0':
- resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ '@pnpm/exe.darwin-x64@12.3.4':
+ resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
cpu: [x64]
os: [darwin]
- '@pnpm/exe.linux-arm64-musl@12.0.0':
- resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
+ resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-arm64@12.0.0':
- resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ '@pnpm/exe.linux-arm64@12.3.4':
+ resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.linux-x64-musl@12.0.0':
- resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ '@pnpm/exe.linux-x64-musl@12.3.4':
+ resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/exe.linux-x64@12.0.0':
- resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ '@pnpm/exe.linux-x64@12.3.4':
+ resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@pnpm/exe.win32-arm64@12.0.0':
- resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ '@pnpm/exe.win32-arm64@12.3.4':
+ resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
cpu: [arm64]
os: [win32]
- '@pnpm/exe.win32-x64@12.0.0':
- resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ '@pnpm/exe.win32-x64@12.3.4':
+ resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
cpu: [x64]
os: [win32]
- pnpm@12.0.0:
- resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ pnpm@12.3.4:
+ resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
- '@pnpm/exe.darwin-arm64@12.0.0':
+ '@pnpm/exe.darwin-arm64@12.3.4':
optional: true
- '@pnpm/exe.darwin-x64@12.0.0':
+ '@pnpm/exe.darwin-x64@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64-musl@12.0.0':
+ '@pnpm/exe.linux-arm64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-arm64@12.0.0':
+ '@pnpm/exe.linux-arm64@12.3.4':
optional: true
- '@pnpm/exe.linux-x64-musl@12.0.0':
+ '@pnpm/exe.linux-x64-musl@12.3.4':
optional: true
- '@pnpm/exe.linux-x64@12.0.0':
+ '@pnpm/exe.linux-x64@12.3.4':
optional: true
- '@pnpm/exe.win32-arm64@12.0.0':
+ '@pnpm/exe.win32-arm64@12.3.4':
optional: true
- '@pnpm/exe.win32-x64@12.0.0':
+ '@pnpm/exe.win32-x64@12.3.4':
optional: true
- pnpm@12.0.0:
+ pnpm@12.3.4:
optionalDependencies:
- '@pnpm/exe.darwin-arm64': 12.0.0
- '@pnpm/exe.darwin-x64': 12.0.0
- '@pnpm/exe.linux-arm64': 12.0.0
- '@pnpm/exe.linux-arm64-musl': 12.0.0
- '@pnpm/exe.linux-x64': 12.0.0
- '@pnpm/exe.linux-x64-musl': 12.0.0
- '@pnpm/exe.win32-arm64': 12.0.0
- '@pnpm/exe.win32-x64': 12.0.0
+ '@pnpm/exe.darwin-arm64': 12.3.4
+ '@pnpm/exe.darwin-x64': 12.3.4
+ '@pnpm/exe.linux-arm64': 12.3.4
+ '@pnpm/exe.linux-arm64-musl': 12.3.4
+ '@pnpm/exe.linux-x64': 12.3.4
+ '@pnpm/exe.linux-x64-musl': 12.3.4
+ '@pnpm/exe.win32-arm64': 12.3.4
+ '@pnpm/exe.win32-x64': 12.3.4
---
lockfileVersion: '9.0'
diff --git a/render-wasm/pnpm-workspace.yaml b/render-wasm/pnpm-workspace.yaml
index 09a02ca1c8..cad1aaed6b 100644
--- a/render-wasm/pnpm-workspace.yaml
+++ b/render-wasm/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+storeDir: ../.pnpm-store
+
allowBuilds:
esbuild: true
onlyBuiltDependencies:
diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots
index 43a23421ca..68140fefdd 100755
--- a/render-wasm/preview-snapshots
+++ b/render-wasm/preview-snapshots
@@ -12,6 +12,7 @@
#
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
+# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
#
# When a test produced a pending change there will be a `*.snap.new` next to the
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
@@ -27,10 +28,14 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts"
+IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
+IMAGE_DIR="$OUT_DIR/images"
mkdir -p "$OUT_DIR"
mkdir -p "$FONT_DIR"
+mkdir -p "$IMAGE_DIR"
cp "$FONT_SRC" "$FONT_DIR/"
+cp "$IMAGE_SRC" "$IMAGE_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes).
diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs
index b7956075a6..26e149d97c 100644
--- a/render-wasm/src/globals.rs
+++ b/render-wasm/src/globals.rs
@@ -124,14 +124,21 @@ macro_rules! with_current_shape {
#[cfg(test)]
pub(crate) struct TestRenderResourcesGuard {
prev: *mut RenderResources,
+ _lock: std::sync::MutexGuard<'static, ()>,
}
+#[cfg(test)]
+static TEST_RENDER_RESOURCES_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
#[cfg(test)]
impl TestRenderResourcesGuard {
pub(crate) fn install(resources: &mut RenderResources) -> Self {
+ let lock = TEST_RENDER_RESOURCES_LOCK
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
let prev = unsafe { RENDER_RESOURCES };
unsafe { RENDER_RESOURCES = resources as *mut _ };
- Self { prev }
+ Self { prev, _lock: lock }
}
}
diff --git a/render-wasm/src/math/bools.rs b/render-wasm/src/math/bools.rs
index c1f5b82edc..cb300d83d9 100644
--- a/render-wasm/src/math/bools.rs
+++ b/render-wasm/src/math/bools.rs
@@ -268,7 +268,6 @@ fn difference(
.iter()
.filter(|s| path_a.contains(to_point(s.evaluate(TValue::Parametric(0.5)))))
.copied()
- .map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
@@ -278,16 +277,53 @@ fn difference(
fn exclusion(segments_a: Vec, segments_b: Vec) -> Vec<(BezierSource, Bezier)> {
let mut result = Vec::new();
result.extend(segments_a.iter().copied().map(|b| (BezierSource::A, b)));
- result.extend(
- segments_b
- .iter()
- .copied()
- .map(|s| s.reverse())
- .map(|b| (BezierSource::B, b)),
- );
+ result.extend(segments_b.iter().copied().map(|b| (BezierSource::B, b)));
result
}
+// Mirrors `app.common.types.path.subpath/clockwise?`.
+fn is_clockwise(path: &Path) -> bool {
+ let mut points: Vec<(f32, f32)> = Vec::new();
+
+ for segment in path.segments().iter() {
+ match *segment {
+ Segment::MoveTo(p) => {
+ if !points.is_empty() {
+ break;
+ }
+ points.push(p);
+ }
+ Segment::LineTo(p) => points.push(p),
+ Segment::CurveTo((_, _, p)) => points.push(p),
+ Segment::Close => break,
+ }
+ }
+
+ if points.len() < 3 {
+ return false;
+ }
+
+ let mut signed_area = 0.0f64;
+ for i in 0..points.len() {
+ let (x1, y1) = points[i];
+ let (x2, y2) = points[(i + 1) % points.len()];
+ signed_area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
+ }
+
+ signed_area > 0.0
+}
+
+// The kept pieces of B must point the same way round as the kept pieces of A. Not the
+// `path.bool/content-bool-pair` rule, which reverses intersection on same winding and
+// relies on `subpath/merge-paths` flipping subpaths when it joins them.
+fn should_reverse_b(bool_type: BoolType, a_is_clockwise: bool, path_b: &Path) -> bool {
+ let same_winding = a_is_clockwise == is_clockwise(path_b);
+ match bool_type {
+ BoolType::Union | BoolType::Intersection => !same_winding,
+ BoolType::Difference | BoolType::Exclusion => same_winding,
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Copy)]
enum BezierSource {
A,
@@ -305,17 +341,14 @@ fn pop_first_from_pool(pool: &mut BezierPool) -> Option<(BezierSource, Bezier)>
pool.iter_mut().find_map(|e| e.take())
}
-// Find and remove the segment whose start point is closest to `end` within the
-// appropriate threshold. Same-source segments use a tight threshold
-// (INTERSECT_THRESHOLD_SAMEd) so we prefer staying on the same original path;
-// cross-source segments use a wider threshold (INTERSECT_THRESHOLD_DIFFERENT)
-// to allow switching paths at intersection points.
+// Same-source candidates get a tighter threshold so we stay on the original path. A
+// candidate that joins by its `end` points the wrong way, so we reverse it.
fn find_next_in_pool(
pool: &mut BezierPool,
end: DVec2,
source: BezierSource,
) -> Option<(BezierSource, Bezier)> {
- let mut best_idx: Option = None;
+ let mut best: Option<(usize, bool)> = None;
let mut best_dist_sq = f64::MAX;
for (i, entry) in pool.iter().enumerate() {
@@ -327,16 +360,21 @@ fn find_next_in_pool(
} else {
INTERSECT_THRESHOLD_DIFFERENT as f64
};
- let dx = bezier.start.x - end.x;
- let dy = bezier.start.y - end.y;
- let dist_sq = dx * dx + dy * dy;
- if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
- best_dist_sq = dist_sq;
- best_idx = Some(i);
+ for (reversed, point) in [(false, bezier.start), (true, bezier.end)] {
+ let dx = point.x - end.x;
+ let dy = point.y - end.y;
+ let dist_sq = dx * dx + dy * dy;
+ if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
+ best_dist_sq = dist_sq;
+ best = Some((i, reversed));
+ }
}
}
- best_idx.and_then(|i| pool[i].take())
+ let (idx, reversed) = best?;
+ pool[idx]
+ .take()
+ .map(|(src, bezier)| (src, if reversed { bezier.reverse() } else { bezier }))
}
fn push_bezier(result: &mut Vec, bezier: &Bezier) {
@@ -410,32 +448,45 @@ fn beziers_to_segments(beziers: &[(BezierSource, Bezier)]) -> Vec {
result
}
-pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
- if children_ids.is_empty() {
- return Path::default();
+fn bool_beziers(
+ bool_type: BoolType,
+ path_a: &Path,
+ a_is_clockwise: bool,
+ path_b: &Path,
+) -> (Vec<(BezierSource, Bezier)>, bool) {
+ let (segs_a, mut segs_b) = split_segments(path_a, path_b);
+
+ if should_reverse_b(bool_type, a_is_clockwise, path_b) {
+ for segment in segs_b.iter_mut() {
+ *segment = segment.reverse();
+ }
}
- let Some(child) = shapes.get(&children_ids[children_ids.len() - 1]) else {
+ let beziers = match bool_type {
+ BoolType::Union => union(path_a, segs_a, path_b, segs_b),
+ BoolType::Difference => difference(path_a, segs_a, path_b, segs_b),
+ BoolType::Intersection => intersection(path_a, segs_a, path_b, segs_b),
+ BoolType::Exclusion => exclusion(segs_a, segs_b),
+ };
+
+ (beziers, path_a.is_even_odd() || path_b.is_even_odd())
+}
+
+// Fold `paths` left to right; the first entry is the base operand.
+fn bool_fold(bool_type: BoolType, paths: &[Path]) -> Path {
+ let Some((first, rest)) = paths.split_first() else {
return Path::default();
};
- let mut current_path = child.to_path(shapes);
+ let mut current_path = first.clone();
+ // Every fold step chains A's fragments, which keep their direction, so the
+ // accumulated path keeps this winding. Carry it instead of re-reading it from the
+ // emitted segment list, whose subpath order and direction fall out of pool ordering.
+ let is_clockwise_a = is_clockwise(¤t_path);
- for idx in (0..children_ids.len() - 1).rev() {
- let Some(other) = shapes.get(&children_ids[idx]) else {
- continue;
- };
- let other_path = other.to_path(shapes);
-
- let (segs_a, segs_b) = split_segments(¤t_path, &other_path);
-
- let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
- let beziers = match bool_type {
- BoolType::Union => union(¤t_path, segs_a, &other_path, segs_b),
- BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b),
- BoolType::Intersection => intersection(¤t_path, segs_a, &other_path, segs_b),
- BoolType::Exclusion => exclusion(segs_a, segs_b),
- };
+ for other_path in rest {
+ let (beziers, is_even_odd) =
+ bool_beziers(bool_type, ¤t_path, is_clockwise_a, other_path);
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
}
@@ -443,6 +494,16 @@ pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: Shap
current_path
}
+pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
+ let paths: Vec = children_ids
+ .iter()
+ .rev()
+ .filter_map(|id| shapes.get(id).map(|child| child.to_path(shapes)))
+ .collect();
+
+ bool_fold(bool_type, &paths)
+}
+
pub fn update_bool_to_path(shape: &mut Shape, shapes: ShapesPoolRef) {
let children_ids = shape.children_ids(true);
@@ -481,6 +542,7 @@ pub fn debug_render_bool_paths(
};
let mut current_path = child.to_path(shapes);
+ let is_clockwise_a = is_clockwise(¤t_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
@@ -488,15 +550,12 @@ pub fn debug_render_bool_paths(
};
let other_path = other.to_path(shapes);
- let (segs_a, segs_b) = split_segments(¤t_path, &other_path);
-
- let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
- let beziers = match bool_data.bool_type {
- BoolType::Union => union(¤t_path, segs_a, &other_path, segs_b),
- BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b),
- BoolType::Intersection => intersection(¤t_path, segs_a, &other_path, segs_b),
- BoolType::Exclusion => exclusion(segs_a, segs_b),
- };
+ let (beziers, is_even_odd) = bool_beziers(
+ bool_data.bool_type,
+ ¤t_path,
+ is_clockwise_a,
+ &other_path,
+ );
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
if idx == 0 {
@@ -572,3 +631,210 @@ pub fn debug_render_bool_paths(
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn linear(from: (f64, f64), to: (f64, f64)) -> Bezier {
+ Bezier::from_linear_coordinates(from.0, from.1, to.0, to.1)
+ }
+
+ fn polygon(points: &[(f32, f32)]) -> Path {
+ let mut segments = vec![Segment::MoveTo(points[0])];
+ segments.extend(points[1..].iter().map(|p| Segment::LineTo(*p)));
+ segments.push(Segment::Close);
+ Path::new(segments)
+ }
+
+ fn count(segments: &[Segment], f: fn(&Segment) -> bool) -> usize {
+ segments.iter().filter(|s| f(s)).count()
+ }
+
+ fn is_move_to(s: &Segment) -> bool {
+ matches!(s, Segment::MoveTo(_))
+ }
+
+ fn is_close(s: &Segment) -> bool {
+ matches!(s, Segment::Close)
+ }
+
+ fn ring_area(ring: &[(f32, f32)]) -> f64 {
+ let mut area = 0.0f64;
+ for i in 0..ring.len() {
+ let (x1, y1) = ring[i];
+ let (x2, y2) = ring[(i + 1) % ring.len()];
+ area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
+ }
+ area / 2.0
+ }
+
+ fn signed_area(segments: &[Segment]) -> f64 {
+ let mut total = 0.0f64;
+ let mut ring: Vec<(f32, f32)> = Vec::new();
+
+ for segment in segments {
+ match *segment {
+ Segment::MoveTo(p) => {
+ total += ring_area(&ring);
+ ring.clear();
+ ring.push(p);
+ }
+ Segment::LineTo(p) | Segment::CurveTo((_, _, p)) => ring.push(p),
+ Segment::Close => {
+ total += ring_area(&ring);
+ ring.clear();
+ }
+ }
+ }
+
+ total + ring_area(&ring)
+ }
+
+ // Operands and expected results taken from the CLJS bool (`app.common.types.path.bool`)
+ // run on the same shapes: A clockwise, B and C counter-clockwise, all overlapping.
+ const A_CW: [(f32, f32); 4] = [
+ (100.0, 100.0),
+ (300.0, 100.0),
+ (300.0, 300.0),
+ (100.0, 300.0),
+ ];
+ const B_CCW: [(f32, f32); 4] = [
+ (200.0, 200.0),
+ (200.0, 400.0),
+ (400.0, 400.0),
+ (400.0, 200.0),
+ ];
+ const C_CCW: [(f32, f32); 4] = [(60.0, 240.0), (60.0, 360.0), (260.0, 360.0), (260.0, 240.0)];
+
+ #[test]
+ fn test_is_clockwise() {
+ let cw = Path::new(vec![
+ Segment::MoveTo((0.0, 0.0)),
+ Segment::LineTo((10.0, 0.0)),
+ Segment::LineTo((10.0, 10.0)),
+ Segment::LineTo((0.0, 10.0)),
+ Segment::Close,
+ ]);
+ assert!(is_clockwise(&cw));
+
+ let ccw = Path::new(vec![
+ Segment::MoveTo((0.0, 0.0)),
+ Segment::LineTo((0.0, 10.0)),
+ Segment::LineTo((10.0, 10.0)),
+ Segment::LineTo((10.0, 0.0)),
+ Segment::Close,
+ ]);
+ assert!(!is_clockwise(&ccw));
+ }
+
+ #[test]
+ fn test_should_reverse_b_only_depends_on_relative_winding() {
+ let cw = Path::new(vec![
+ Segment::MoveTo((0.0, 0.0)),
+ Segment::LineTo((10.0, 0.0)),
+ Segment::LineTo((10.0, 10.0)),
+ Segment::LineTo((0.0, 10.0)),
+ Segment::Close,
+ ]);
+ let ccw = Path::new(vec![
+ Segment::MoveTo((0.0, 0.0)),
+ Segment::LineTo((0.0, 10.0)),
+ Segment::LineTo((10.0, 10.0)),
+ Segment::LineTo((10.0, 0.0)),
+ Segment::Close,
+ ]);
+
+ assert!(should_reverse_b(BoolType::Difference, true, &cw));
+ assert!(!should_reverse_b(BoolType::Difference, true, &ccw));
+ assert!(!should_reverse_b(BoolType::Union, true, &cw));
+ assert!(should_reverse_b(BoolType::Union, true, &ccw));
+ }
+
+ // Fragments from #11482: two point the wrong way, so joining them start-to-start only
+ // left five open subpaths.
+ #[test]
+ fn test_beziers_to_segments_closes_reversed_fragments() {
+ let beziers = vec![
+ (
+ BezierSource::A,
+ linear((2764.00, -240.00), (2834.74, -110.71)),
+ ),
+ (
+ BezierSource::A,
+ linear((2809.29, -85.26), (2693.26, -201.29)),
+ ),
+ (
+ BezierSource::A,
+ linear((2718.71, -226.74), (2764.00, -240.00)),
+ ),
+ (
+ BezierSource::B,
+ linear((2718.71, -226.74), (2834.74, -110.71)),
+ ),
+ (
+ BezierSource::B,
+ linear((2809.29, -85.26), (2693.26, -201.29)),
+ ),
+ ];
+
+ let segments = beziers_to_segments(&beziers);
+
+ let moves = segments
+ .iter()
+ .filter(|s| matches!(s, Segment::MoveTo(_)))
+ .count();
+ let closes = segments
+ .iter()
+ .filter(|s| matches!(s, Segment::Close))
+ .count();
+
+ assert_eq!(moves, 2);
+ assert_eq!(closes, 2);
+ // 3 fragments in the first subpath, 2 in the second, each dropping its closing LineTo.
+ assert_eq!(segments.len(), 7);
+ }
+
+ // #11482: A clockwise, B counter-clockwise. Reference (CLJS):
+ // M100,100 L300,100 L300,200 L200,200 L200,300 L100,300 Z
+ #[test]
+ fn test_difference_with_opposite_winding_operand() {
+ let result = bool_fold(BoolType::Difference, &[polygon(&A_CW), polygon(&B_CCW)]);
+ let segments = result.segments();
+
+ assert_eq!(count(segments, is_move_to), 1);
+ assert_eq!(count(segments, is_close), 1);
+ assert!((signed_area(segments) - 30000.0).abs() < 1.0);
+ assert!(is_clockwise(&result));
+ }
+
+ // Reference (CLJS):
+ // M100,100 L300,100 L300,200 L400,200 L400,400 L200,400 L200,300 L100,300 Z
+ #[test]
+ fn test_union_with_opposite_winding_operand() {
+ let result = bool_fold(BoolType::Union, &[polygon(&A_CW), polygon(&B_CCW)]);
+ let segments = result.segments();
+
+ assert_eq!(count(segments, is_move_to), 1);
+ assert_eq!(count(segments, is_close), 1);
+ assert!((signed_area(segments) - 70000.0).abs() < 1.0);
+ assert!(is_clockwise(&result));
+ }
+
+ // The second fold step must compare against A's winding, not against the winding of
+ // the intermediate path, whose subpath order and direction fall out of pool ordering.
+ // Reference (CLJS): M100,100 L300,100 L300,200 L200,200 L200,240 L100,240 Z
+ #[test]
+ fn test_difference_folds_three_opposite_winding_operands() {
+ let result = bool_fold(
+ BoolType::Difference,
+ &[polygon(&A_CW), polygon(&B_CCW), polygon(&C_CCW)],
+ );
+ let segments = result.segments();
+
+ assert_eq!(count(segments, is_move_to), 1);
+ assert_eq!(count(segments, is_close), 1);
+ assert!((signed_area(segments) - 24000.0).abs() < 1.0);
+ assert!(is_clockwise(&result));
+ }
+}
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index 64767b29bb..c2ee801c41 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -720,7 +720,12 @@ impl RenderState {
/// Renders background blur effect directly to the given target surface.
/// Must be called BEFORE any save_layer for the shape's own opacity/blend,
/// so that the backdrop blur is independent of the shape's visual properties.
- fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
+ fn render_background_blur(
+ &mut self,
+ shape: &Shape,
+ clip_bounds: Option<&ClipStack>,
+ target_surface: SurfaceId,
+ ) {
if self.options.is_fast_mode() {
return;
}
@@ -760,8 +765,14 @@ impl RenderState {
matrix.post_translate(center);
matrix.pre_translate(-center);
+ self.surfaces.canvas(target_surface).save();
+
+ if let Some(clips) = clip_bounds {
+ let antialias = shape.should_use_antialias(scale, self.options.antialias_threshold);
+ self.clip_target_surface_to_stack(clips, target_surface, scale, antialias);
+ }
+
let canvas = self.surfaces.canvas(target_surface);
- canvas.save();
// Current/Export have no render context transform (identity canvas).
// Apply scale + translate + shape transform so the clip maps
@@ -3804,7 +3815,7 @@ impl RenderState {
// Render background blur BEFORE save_layer so it modifies
// the backdrop independently of the shape's opacity.
if !node_render_state.is_root() && self.focus_mode.is_active() {
- self.render_background_blur(element, target_surface);
+ self.render_background_blur(element, clip_bounds.as_ref(), target_surface);
}
self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface);
diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs
index bebd20f7c6..ac740b064d 100644
--- a/render-wasm/src/render/fills.rs
+++ b/render-wasm/src/render/fills.rs
@@ -3,7 +3,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::Result;
use crate::get_resources;
-use crate::render::get_source_rect;
+use crate::render::{get_image_dest_rect, get_source_rect};
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
// Set the clipping area to the shape outline within the container bounds
@@ -91,11 +91,12 @@ fn draw_image_fill(
let size = image.dimensions();
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
-
- let src_rect = get_source_rect(size, container, image_fill);
- let dest_rect = container;
let sampling = get_resources().sampling_options;
+ let dest_rect = get_image_dest_rect(container, image_fill);
+ let src_rect = get_source_rect(size, &dest_rect, image_fill);
+ let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape);
+
// `save_layer` is only required when a shape-level image filter (blur) must
// run over the clipped image. Otherwise a plain save/clip (or no clip for
// axis-aligned rects) avoids an offscreen buffer per fill — the hot path
@@ -121,7 +122,7 @@ fn draw_image_fill(
let mut draw_paint = paint.clone();
draw_paint.set_anti_alias(antialias);
- if is_axis_aligned_image_rect(shape) {
+ if !needs_clip {
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
@@ -163,10 +164,6 @@ fn draw_svg_image_fill(
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let size = skia::ISize::new(size.width as i32, size.height as i32);
- let src_rect = get_source_rect(size, container, image_fill);
- if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 {
- return true;
- }
let mut image_paint = skia::Paint::default();
image_paint.set_anti_alias(antialias);
@@ -183,16 +180,22 @@ fn draw_svg_image_fill(
let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint);
canvas.save_layer(&fill_layer);
- // Map the cropped source rect onto the container: cover semantics when
- // keep-aspect-ratio is set, stretch otherwise (same math as the raster
- // path, expressed as a canvas transform).
- let scale_x = container.width() / src_rect.width();
- let scale_y = container.height() / src_rect.height();
+ let dest_rect = get_image_dest_rect(container, image_fill);
+ let src_rect = get_source_rect(size, &dest_rect, image_fill);
+ if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 {
+ canvas.restore();
+ canvas.restore();
+ return true;
+ }
+
+ let scale_x = dest_rect.width() / src_rect.width();
+ let scale_y = dest_rect.height() / src_rect.height();
canvas.translate((
- container.left - src_rect.left * scale_x,
- container.top - src_rect.top * scale_y,
+ dest_rect.left - src_rect.left * scale_x,
+ dest_rect.top - src_rect.top * scale_y,
));
canvas.scale((scale_x, scale_y));
+
dom.render(canvas);
canvas.restore();
diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs
index 79b5e5653a..4b1ce66754 100644
--- a/render-wasm/src/render/images.rs
+++ b/render-wasm/src/render/images.rs
@@ -20,6 +20,18 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect {
)
}
+pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect {
+ match image_fill.transform() {
+ Some(tf) => MathRect::from_xywh(
+ container.left + tf.x * container.width(),
+ container.top + tf.y * container.height(),
+ tf.width * container.width(),
+ tf.height * container.height(),
+ ),
+ None => *container,
+ }
+}
+
pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect {
let image_width = size.width as f32;
let image_height = size.height as f32;
@@ -82,6 +94,9 @@ pub struct ImageStore {
tick: Cell,
/// gpu-only
context: Option>,
+ /// Source URL registered when the image was fetched (SVG export references
+ /// this in linked `` elements).
+ source_urls: HashMap,
}
/// Creates a Skia image from an existing WebGL texture.
@@ -227,6 +242,7 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: Some(Box::new(context.clone())),
+ source_urls: HashMap::new(),
}
}
@@ -239,6 +255,7 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: None,
+ source_urls: HashMap::new(),
}
}
@@ -476,4 +493,14 @@ impl ImageStore {
None
}
}
+
+ pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
+ if !url.is_empty() {
+ self.source_urls.insert(id, url);
+ }
+ }
+
+ pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
+ self.source_urls.get(id).map(String::as_str)
+ }
}
diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs
index 083c08f920..a36b9ee741 100644
--- a/render-wasm/src/render/svg/document.rs
+++ b/render-wasm/src/render/svg/document.rs
@@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint};
-use crate::shapes::{Shape, Type};
+use crate::shapes::{radius_to_sigma, Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
- pub(super) scale: f32,
+ scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
@@ -97,6 +97,28 @@ impl SvgLayerCanvas {
self.out.push_str("");
}
+ /// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
+ pub(super) fn push_raw(&mut self, markup: &str) {
+ self.flush();
+ self.out.push_str(markup);
+ }
+
+ /// CTM for leaf content placed in page space: Scale * Translate * Centered.
+ pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
+ let mut ctm = skia::Matrix::scale((self.scale, self.scale));
+ ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
+ ctm = ctm * shape.centered_transform();
+ format!(
+ "matrix({} {} {} {} {} {})",
+ ctm.scale_x(),
+ ctm.skew_y(),
+ ctm.skew_x(),
+ ctm.scale_y(),
+ ctm.translate_x(),
+ ctm.translate_y()
+ )
+ }
+
/// Emits a `` from a shape's geometry (in device/page space).
///
/// A mask can be a group too. Since a group has no geometry of its own, we
@@ -114,17 +136,40 @@ impl SvgLayerCanvas {
}
/// Finalizes a fragment canvas as a `` def.
+ ///
+ /// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd
+ /// stroke rings would otherwise fill solid.
pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
- let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix));
+ let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix))
+ .replace("fill-rule=", "clip-rule=");
self.defs.push_str(&format!(
"{geometry} "
));
}
+
+ /// Registers a layer-blur `` and returns its id.
+ ///
+ /// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`).
+ /// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs.
+ pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String {
+ let id = self.unique("blur");
+ self.defs.push_str(&format!(
+ concat!(
+ "",
+ "",
+ " "
+ ),
+ id = id,
+ sigma = sigma
+ ));
+ id
+ }
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
@@ -145,11 +190,11 @@ fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, pai
}
/// Builds the `` attribute string for a shape's composite effects (opacity,
-/// blend mode). Returns `None` when the shape needs no wrapper.
+/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
///
-/// Layer blur / shadows are intentionally omitted here — they need native SVG
-/// filter re-emission to survive `SkSVGDevice` and land in later PRs.
-pub(super) fn effect_attrs(element: &Shape) -> Option {
+/// Layer blur is a native SVG `` (SkSVGDevice drops paint image-filters).
+/// Shadows still need dedicated re-emission in a later PR.
+pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option {
let mut parts: Vec = Vec::new();
let opacity = element.opacity();
@@ -161,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
+ if let Some(blur) = element.visible_layer_blur() {
+ // Match canvas `Shape::image_filter`: sigma from radius × export scale.
+ let sigma = radius_to_sigma(blur.value * builder.scale);
+ let id = builder.push_layer_blur_filter(sigma);
+ parts.push(format!("filter=\"url(#{id})\""));
+ }
+
if parts.is_empty() {
None
} else {
diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs
index 46729b94b0..e97c39c36f 100644
--- a/render-wasm/src/render/svg/fixtures.rs
+++ b/render-wasm/src/render/svg/fixtures.rs
@@ -5,8 +5,9 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources};
use crate::shapes::{
- Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign,
- TextContent, TextDirection, TextSpan, Type,
+ Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
+ SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
+ Type,
};
use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
@@ -17,6 +18,10 @@ use super::render_tree_to_svg;
/// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
+/// Media URL referenced by linked `` fills in SVG export tests.
+/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
+pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
+
fn register_test_font_urls(fonts: &mut FontStore) {
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
@@ -27,6 +32,31 @@ pub(super) fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
+/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
+pub(super) fn add_image_rect(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ image_id: Uuid,
+ keep_aspect_ratio: bool,
+ opacity: u8,
+) {
+ add_rect_with_fills(
+ pool,
+ id,
+ parent,
+ (l, t, r, b),
+ vec![Fill::Image(ImageFill::new(
+ image_id,
+ opacity,
+ 200,
+ 100,
+ keep_aspect_ratio,
+ ))],
+ );
+}
+
/// Adds a solid-filled rectangle to the pool.
pub(super) fn add_solid_rect(
pool: &mut ShapesPool,
@@ -67,15 +97,101 @@ pub(super) fn add_frame(
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
clip: bool,
+) {
+ add_frame_with_fills(
+ pool,
+ id,
+ parent,
+ (l, t, r, b),
+ vec![Fill::Solid(SolidColor(color))],
+ clip,
+ );
+}
+
+fn add_frame_with_fills(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ fills: Vec,
+ clip: bool,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Frame(Frame::default()));
shape.set_selrect(l, t, r, b);
- shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
+ shape.set_fills(fills);
shape.set_clip(clip);
}
+/// Frame whose background is a linked image fill.
+pub(super) fn add_image_frame(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ image_id: Uuid,
+ clip: bool,
+) {
+ add_frame_with_fills(
+ pool,
+ id,
+ parent,
+ (l, t, r, b),
+ vec![test_image_fill(image_id)],
+ clip,
+ );
+}
+
+fn triangle_segments(closed: bool) -> Vec {
+ let mut segments = vec![
+ Segment::MoveTo((10.0, 90.0)),
+ Segment::LineTo((50.0, 10.0)),
+ Segment::LineTo((90.0, 90.0)),
+ ];
+ if closed {
+ segments.push(Segment::Close);
+ }
+ segments
+}
+
+fn add_path_with_fills(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ segments: Vec,
+ fills: Vec,
+) {
+ let shape = pool.add_shape(id);
+ shape.set_parent(parent);
+ shape.set_shape_type(Type::Path(Path::new(segments)));
+ shape.set_selrect(l, t, r, b);
+ shape.set_fills(fills);
+}
+
+fn test_image_fill(image_id: Uuid) -> Fill {
+ Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
+}
+
+/// Triangle path (open or closed) with a linked image fill.
+pub(super) fn add_image_path(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ closed: bool,
+ image_id: Uuid,
+) {
+ add_path_with_fills(
+ pool,
+ id,
+ parent,
+ (0.0, 0.0, 100.0, 100.0),
+ triangle_segments(closed),
+ vec![test_image_fill(image_id)],
+ );
+}
+
/// Adds an empty (unmasked) group.
pub(super) fn add_group(
pool: &mut ShapesPool,
@@ -93,6 +209,20 @@ pub(super) fn add_group(
}
}
+/// SVG-raw leaf with markup (same form `get-static-markup` uploads to WASM).
+pub(super) fn add_svg_raw(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ content: &str,
+) {
+ let shape = pool.add_shape(id);
+ shape.set_parent(parent);
+ shape.set_svg_raw_content(content.to_string());
+ shape.set_selrect(l, t, r, b);
+}
+
/// Adds a single-line text shape using the embedded default font.
pub(super) fn add_solid_text(
pool: &mut ShapesPool,
@@ -153,9 +283,152 @@ pub(super) fn add_text_with_fills(
shape.set_shape_type(Type::Text(content));
}
+/// Adds a rectangle with a single solid stroke (no fill).
+pub(super) fn add_stroked_rect(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ stroke: Stroke,
+) {
+ let shape = pool.add_shape(id);
+ shape.set_parent(parent);
+ shape.set_shape_type(Type::Rect(Rect::default()));
+ shape.set_selrect(l, t, r, b);
+ shape.set_fills(vec![]);
+ shape.add_stroke(stroke);
+}
+
+/// Adds a closed rectangular path with a single solid stroke (no fill).
+pub(super) fn add_stroked_closed_path(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ bounds: (f32, f32, f32, f32),
+ stroke: Stroke,
+) {
+ add_stroked_path(pool, id, parent, bounds, stroke, true);
+}
+
+/// Adds an open polyline path with a single solid stroke (no fill).
+pub(super) fn add_stroked_open_path(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ bounds: (f32, f32, f32, f32),
+ stroke: Stroke,
+) {
+ add_stroked_path(pool, id, parent, bounds, stroke, false);
+}
+
+fn add_stroked_path(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ parent: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ stroke: Stroke,
+ closed: bool,
+) {
+ let mut segments = vec![
+ Segment::MoveTo((l, t)),
+ Segment::LineTo((r, t)),
+ Segment::LineTo((r, b)),
+ Segment::LineTo((l, b)),
+ ];
+ if closed {
+ segments.push(Segment::Close);
+ }
+ let path = Path::new(segments);
+ let shape = pool.add_shape(id);
+ shape.set_parent(parent);
+ shape.set_shape_type(Type::Path(path));
+ shape.set_selrect(l, t, r, b);
+ shape.set_fills(vec![]);
+ shape.add_stroke(stroke);
+}
+
+pub(super) fn solid_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
+ stroke_with_style(kind, StrokeStyle::Solid, width, color)
+}
+
+pub(super) fn dotted_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
+ stroke_with_style(kind, StrokeStyle::Dotted, width, color)
+}
+
+pub(super) fn dashed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
+ stroke_with_style(kind, StrokeStyle::Dashed, width, color)
+}
+
+pub(super) fn mixed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke {
+ stroke_with_style(kind, StrokeStyle::Mixed, width, color)
+}
+
+fn stroke_with_style(
+ kind: StrokeKind,
+ style: StrokeStyle,
+ width: f32,
+ color: skia::Color,
+) -> Stroke {
+ let mut stroke = match kind {
+ StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
+ StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
+ StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
+ };
+ stroke.fill = Fill::Solid(SolidColor(color));
+ stroke
+}
+
+fn image_stroke(kind: StrokeKind, style: StrokeStyle, width: f32, image_id: Uuid) -> Stroke {
+ let mut stroke = match kind {
+ StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
+ StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
+ StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
+ };
+ stroke.fill = test_image_fill(image_id);
+ stroke
+}
+
+pub(super) fn image_solid_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke {
+ image_stroke(kind, StrokeStyle::Solid, width, image_id)
+}
+
+pub(super) fn image_dotted_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke {
+ image_stroke(kind, StrokeStyle::Dotted, width, image_id)
+}
+
+/// Text with a linked image fill (register URL via `render_with`).
+pub(super) fn add_image_text(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ bounds: (f32, f32, f32, f32),
+ text: &str,
+ font_size: f32,
+ image_id: Uuid,
+) {
+ add_text_with_fills(
+ pool,
+ id,
+ bounds,
+ text,
+ font_size,
+ vec![test_image_fill(image_id)],
+ );
+}
+
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
+ render_with(pool, root, |_resources| {})
+}
+
+/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
+/// before export.
+pub(super) fn render_with(
+ pool: &ShapesPool,
+ root: Uuid,
+ setup: impl FnOnce(&mut RenderResources),
+) -> String {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts);
+ setup(&mut resources);
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
diff --git a/render-wasm/src/render/svg/fixtures/test-fill.svg b/render-wasm/src/render/svg/fixtures/test-fill.svg
new file mode 100644
index 0000000000..9d7fae920e
--- /dev/null
+++ b/render-wasm/src/render/svg/fixtures/test-fill.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+ IMG
+
diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs
index fead20bbf4..f4f9f2de9c 100644
--- a/render-wasm/src/render/svg/frames.rs
+++ b/render-wasm/src/render/svg/frames.rs
@@ -1,10 +1,9 @@
use crate::error::Result;
-use crate::render::shape_renderer::ShapeRenderer;
-use crate::render::vector::VectorRenderer;
use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
+use super::images::{emit_fills, emit_strokes};
use super::render_tree;
use crate::render::RenderResources;
@@ -15,9 +14,7 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
- let matrix = element.centered_transform();
-
- let effects = effect_attrs(element);
+ let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
@@ -29,14 +26,9 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
- // Frame background (frame space).
+ // Frame background (frame space), with linked `` for image fills.
if !element.fills.is_empty() {
- let canvas = builder.canvas();
- canvas.save();
- canvas.concat(&matrix);
- let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
- renderer.draw_fills(element, &element.fills)?;
- canvas.restore();
+ emit_fills(builder, shared, element, &element.fills, tree, scale)?;
}
// Children (absolute coords).
@@ -45,20 +37,19 @@ pub(super) fn render_frame(
render_tree(builder, shared, child_id, tree, scale)?;
}
- // Strokes over children (frame space).
- let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
- if !visible_strokes.is_empty() {
- let canvas = builder.canvas();
- canvas.save();
- canvas.concat(&matrix);
- let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
- renderer.draw_strokes(element, &visible_strokes)?;
- canvas.restore();
- }
-
+ // Close content clip before strokes. Outer (and half of center) strokes
+ // extend past the frame bounds; keeping them under clip-path hides them.
+ // Matches GPU: clipped-frame strokes render in exit without the frame clip.
if clipped {
builder.close_group();
}
+
+ // Strokes over children (frame space), outside the content clip.
+ let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
+ if !visible_strokes.is_empty() {
+ emit_strokes(builder, shared, element, &visible_strokes, scale)?;
+ }
+
if effects.is_some() {
builder.close_group();
}
diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs
index 5bca7d8876..cd2359baef 100644
--- a/render-wasm/src/render/svg/groups.rs
+++ b/render-wasm/src/render/svg/groups.rs
@@ -13,7 +13,7 @@ pub(super) fn render_group(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
- let effects = effect_attrs(element);
+ let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs
new file mode 100644
index 0000000000..e072100758
--- /dev/null
+++ b/render-wasm/src/render/svg/images.rs
@@ -0,0 +1,201 @@
+use crate::error::Result;
+use crate::math::Rect as MathRect;
+use crate::render::get_dest_rect;
+use crate::render::get_image_dest_rect;
+use crate::render::shape_renderer::ShapeRenderer;
+use crate::render::vector::{paint_svg_stroke_silhouette, VectorRenderer};
+use crate::shapes::{Fill, ImageFill, Shape, Stroke};
+use crate::state::ShapesPoolRef;
+
+use super::document::SvgLayerCanvas;
+use crate::render::RenderResources;
+
+/// Emits fills bottom -> top for SVG export.
+///
+/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
+/// source URL become native linked `` elements (see `store_image_url`);
+/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
+pub(super) fn emit_fills(
+ builder: &mut SvgLayerCanvas,
+ shared: &mut RenderResources,
+ shape: &Shape,
+ fills: &[Fill],
+ tree: ShapesPoolRef,
+ scale: f32,
+) -> Result<()> {
+ if fills.is_empty() {
+ return Ok(());
+ }
+
+ // fills[0] is the topmost layer; draw bottom → top.
+ for fill in fills.iter().rev() {
+ match fill {
+ Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
+ emit_image_fill(builder, shared, shape, image_fill, tree)?;
+ }
+ fill => {
+ let matrix = shape.centered_transform();
+ let canvas = builder.canvas();
+ canvas.save();
+ canvas.concat(&matrix);
+ let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
+ renderer.draw_fills(shape, std::slice::from_ref(fill))?;
+ canvas.restore();
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Emits a linked SVG `` clipped to the shape geometry.
+///
+/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
+/// a native `` instead so the export stays linked to the
+/// registered media URL (see `store_image_url`).
+fn emit_image_fill(
+ builder: &mut SvgLayerCanvas,
+ shared: &RenderResources,
+ shape: &Shape,
+ image_fill: &ImageFill,
+ tree: ShapesPoolRef,
+) -> Result<()> {
+ let Some(url) = shared.images.source_url(&image_fill.id()) else {
+ return Ok(());
+ };
+
+ let clip_id = builder.unique("imgclip");
+ builder.push_clip_path(&clip_id, shape, tree);
+ let href = xml_escape_attr(url);
+ let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
+ emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
+ Ok(())
+}
+
+/// Emits strokes bottom -> top for SVG export.
+///
+/// Image strokes with a registered URL become a linked `` clipped to the
+/// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes
+/// go through [`VectorRenderer`].
+pub(super) fn emit_strokes(
+ builder: &mut SvgLayerCanvas,
+ shared: &mut RenderResources,
+ shape: &Shape,
+ strokes: &[&Stroke],
+ scale: f32,
+) -> Result<()> {
+ if strokes.is_empty() {
+ return Ok(());
+ }
+
+ let matrix = shape.centered_transform();
+ // strokes[0] is topmost; draw bottom -> top.
+ for stroke in strokes.iter().rev() {
+ match &stroke.fill {
+ Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
+ emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?;
+ }
+ _ => {
+ let canvas = builder.canvas();
+ canvas.save();
+ canvas.concat(&matrix);
+ let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
+ renderer.draw_strokes(shape, std::slice::from_ref(stroke))?;
+ canvas.restore();
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Linked `` clipped to the stroke outline (opaque filled path).
+fn emit_image_stroke(
+ builder: &mut SvgLayerCanvas,
+ shared: &RenderResources,
+ shape: &Shape,
+ stroke: &Stroke,
+ image_fill: &ImageFill,
+ scale: f32,
+) -> Result<()> {
+ let Some(url) = shared.images.source_url(&image_fill.id()) else {
+ return Ok(());
+ };
+
+ let clip_id = builder.unique("imgstrokeclip");
+ let canvas = builder.new_fragment();
+ {
+ let cv: &skia_safe::Canvas = &canvas;
+ cv.save();
+ cv.concat(&shape.centered_transform());
+ if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
+ cv.restore();
+ return Ok(());
+ }
+ cv.restore();
+ }
+ builder.finish_clip_path_fragment(&clip_id, canvas);
+
+ let href = xml_escape_attr(url);
+ let dest = image_stroke_dest_rect(shape, stroke);
+ emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id);
+ Ok(())
+}
+
+/// Where to place the linked image for an image-filled stroke.
+///
+/// Starts from the same dest as the GPU path (`selrect` + `stroke.delta()`), then
+/// grows on open paths so marker caps are still covered by the ``.
+fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
+ let mut dest = get_dest_rect(&shape.selrect(), stroke.delta());
+ if !shape.is_open() {
+ return dest;
+ }
+ let cap_margin = stroke.cap_bounds_margin();
+ if cap_margin <= 0.0 {
+ return dest;
+ }
+ let mut with_caps = shape.selrect();
+ with_caps.inset((-cap_margin, -cap_margin));
+ dest.join(with_caps);
+ dest
+}
+
+/// Emits `` + `` at `dest_rect`, under the page CTM.
+pub(super) fn emit_linked_image_element(
+ builder: &mut SvgLayerCanvas,
+ shape: &Shape,
+ image_fill: &ImageFill,
+ dest_rect: MathRect,
+ href: &str,
+ clip_id: &str,
+) {
+ let opacity = image_fill.opacity() as f32 / 255.0;
+ let preserve = if image_fill.keep_aspect_ratio() {
+ "xMidYMid slice"
+ } else {
+ "none"
+ };
+ let transform = builder.page_shape_matrix_attr(shape);
+
+ let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
+ String::new()
+ } else {
+ format!(r#" opacity="{opacity}""#)
+ };
+
+ builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
+ builder.push_raw(&format!(
+ r#" "#,
+ dest_rect.left(),
+ dest_rect.top(),
+ dest_rect.width(),
+ dest_rect.height(),
+ ));
+ builder.close_group();
+}
+
+pub(super) fn xml_escape_attr(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('"', """)
+ .replace('<', "<")
+ .replace('>', ">")
+}
diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs
index e992b1ad66..e3dde7cf84 100644
--- a/render-wasm/src/render/svg/mod.rs
+++ b/render-wasm/src/render/svg/mod.rs
@@ -8,7 +8,8 @@ use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
-use super::vector::{render_leaf_content, VectorRenderer};
+use super::shape_renderer::ShapeRenderer;
+use super::vector::VectorRenderer;
use super::RenderResources;
/// Collects the registered font aliases used by every text span in the subtree
@@ -59,8 +60,10 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// composed as native SVG `` wrappers. Frame `clip content` uses a native
/// ``.
///
-/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
-/// deferred strokes is intentionally out of scope for this cut.
+/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
+/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
+/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
+/// image-filled strokes use a linked `` clipped to the stroke.
pub fn render_to_svg(
shared: &mut RenderResources,
id: &Uuid,
@@ -124,6 +127,7 @@ pub(crate) fn render_tree_to_svg(
mod document;
mod frames;
mod groups;
+mod images;
mod text;
use document::SvgLayerCanvas;
@@ -132,6 +136,7 @@ use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
+use images::{emit_fills, emit_strokes};
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
@@ -171,7 +176,7 @@ fn render_tree(
| Type::Path(_)
| Type::Bool(_)
| Type::Text(_)
- | Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
+ | Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
}
}
@@ -179,24 +184,50 @@ fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
+ tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
- let effects = effect_attrs(element);
+ let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
{
if matches!(element.shape_type, Type::Text(_)) {
- render_text_fill(builder, element)?;
- } else {
+ render_text_fill(builder, shared, element)?;
+ } else if matches!(element.shape_type, Type::SVGRaw(_)) {
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
- render_leaf_content(&mut renderer, element)?;
+ renderer.draw_svg(element)?;
canvas.restore();
+ } else {
+ emit_fills(builder, shared, element, &element.fills, tree, scale)?;
+
+ let matrix = element.centered_transform();
+ let canvas = builder.canvas();
+ canvas.save();
+ canvas.concat(&matrix);
+ let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
+ renderer.draw_fill_inner_shadows(element)?;
+ canvas.restore();
+
+ let visible_strokes: Vec<_> = element.visible_strokes().collect();
+ if !visible_strokes.is_empty() {
+ emit_strokes(builder, shared, element, &visible_strokes, scale)?;
+ if !element.has_fills() {
+ let canvas = builder.canvas();
+ canvas.save();
+ canvas.concat(&matrix);
+ let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
+ for stroke in &visible_strokes {
+ renderer.draw_stroke_inner_shadows(element, stroke)?;
+ }
+ canvas.restore();
+ }
+ }
}
}
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap
new file mode 100644
index 0000000000..2ef3bd3c17
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap
@@ -0,0 +1,12 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap
new file mode 100644
index 0000000000..070cd8441d
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap
new file mode 100644
index 0000000000..d6fa94869e
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap
new file mode 100644
index 0000000000..180801df6e
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap
new file mode 100644
index 0000000000..afc467606b
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap
new file mode 100644
index 0000000000..b25f12faa4
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap
new file mode 100644
index 0000000000..0022609790
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap
new file mode 100644
index 0000000000..69b2aedb7c
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap
new file mode 100644
index 0000000000..8144ac1dd2
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap
new file mode 100644
index 0000000000..22fa54bc00
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap
new file mode 100644
index 0000000000..be53344b75
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap
new file mode 100644
index 0000000000..c34757a0ad
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap
@@ -0,0 +1,9 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap
new file mode 100644
index 0000000000..972225a8f5
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_as_linked_image.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap
new file mode 100644
index 0000000000..c33b82fe72
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_closed_path.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap
new file mode 100644
index 0000000000..470af02231
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_frame.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap
new file mode 100644
index 0000000000..45ab01f3a2
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_open_path.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap
new file mode 100644
index 0000000000..a982089e5b
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_image_fill_on_text.snap
@@ -0,0 +1,11 @@
+---
+source: src/render/svg/tests.rs
+assertion_line: 306
+expression: svg
+---
+
+
+
+ HOLA
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap
new file mode 100644
index 0000000000..208edb618a
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap
new file mode 100644
index 0000000000..2e6d8f7918
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_mixed_solid_and_image_fills_in_order.snap
@@ -0,0 +1,10 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap
new file mode 100644
index 0000000000..375278612c
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap
new file mode 100644
index 0000000000..8977724407
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap
@@ -0,0 +1,11 @@
+---
+source: src/render/svg/tests.rs
+assertion_line: 744
+expression: svg
+---
+
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap
new file mode 100644
index 0000000000..a9a4c6b760
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap
@@ -0,0 +1,10 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap
new file mode 100644
index 0000000000..f40de53a37
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap
new file mode 100644
index 0000000000..19f8aedab2
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap
new file mode 100644
index 0000000000..0d3d73b1a8
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap
new file mode 100644
index 0000000000..8abc827a1f
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap
new file mode 100644
index 0000000000..cd08038591
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap
new file mode 100644
index 0000000000..036f56eab7
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap
new file mode 100644
index 0000000000..82c042efce
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap
new file mode 100644
index 0000000000..94e23b3876
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap
new file mode 100644
index 0000000000..bf0ba3795e
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap
new file mode 100644
index 0000000000..b71a5e4ebc
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap
new file mode 100644
index 0000000000..cac412a2da
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap
new file mode 100644
index 0000000000..5f9da0dcc4
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap
@@ -0,0 +1,9 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap
new file mode 100644
index 0000000000..198993db1f
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap
new file mode 100644
index 0000000000..83935d88e1
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap
new file mode 100644
index 0000000000..51d4849123
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap
new file mode 100644
index 0000000000..66caf336f1
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap
new file mode 100644
index 0000000000..213181290d
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap
new file mode 100644
index 0000000000..96183dfc50
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap
new file mode 100644
index 0000000000..bfc037fe93
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap
@@ -0,0 +1,10 @@
+---
+source: src/render/svg/tests.rs
+assertion_line: 454
+expression: svg
+---
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap
new file mode 100644
index 0000000000..be951f44c1
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap
@@ -0,0 +1,10 @@
+---
+source: src/render/svg/tests.rs
+assertion_line: 367
+expression: svg
+---
+
+
+
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap
new file mode 100644
index 0000000000..ed30b2c40d
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
+
+
diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs
index 5ed0fc81c3..b7048272a0 100644
--- a/render-wasm/src/render/svg/tests.rs
+++ b/render-wasm/src/render/svg/tests.rs
@@ -1,6 +1,9 @@
use super::fixtures::*;
-use crate::shapes::{BlendMode, Fill, SolidColor};
+use crate::shapes::{
+ radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor,
+ StrokeCap, StrokeKind,
+};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -83,6 +86,116 @@ fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() {
insta::assert_snapshot!(svg);
}
+#[test]
+fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_solid_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ skia::Color::from_rgb(255, 0, 0),
+ );
+ let blur_value = 10.0;
+ {
+ let shape = pool.get_mut(&id).unwrap();
+ shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, blur_value)));
+ }
+
+ let svg = render(&pool, id);
+ let expected_sigma = radius_to_sigma(blur_value * 1.0);
+ assert!(
+ svg.contains(""#,
+ r#"HOLA "#,
+ r#""#,
+ ),
+ );
+
+ let resources = crate::render::RenderResources::try_new_headless().expect("headless");
+ let font_manager = skia::FontMgr::from(resources.fonts.font_provider().clone());
+ {
+ let shape = pool.get_mut(&id).unwrap();
+ shape.update_svg_raw_content(font_manager);
+ assert!(shape.svg.is_some(), "Dom must parse like WASM upload");
+ }
+
+ let _svg = render(&pool, id);
+}
+
#[test]
fn exports_a_clipped_frame_with_overflowing_child() {
let mut pool = ShapesPool::new();
@@ -208,6 +351,47 @@ fn exports_an_unclipped_frame_with_overflowing_child() {
insta::assert_snapshot!(svg);
}
+#[test]
+fn exports_clipped_frame_with_solid_outer_stroke() {
+ // Regression: frame content clip must not wrap strokes — outer strokes
+ // sit outside the selrect and would be fully clipped away.
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_frame(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 140.0, 100.0),
+ skia::Color::from_rgb(0xee, 0xee, 0xee),
+ true,
+ );
+ {
+ let shape = pool.get_mut(&id).unwrap();
+ shape.add_stroke(solid_stroke(
+ StrokeKind::Outer,
+ 12.0,
+ skia::Color::from_rgb(0x10, 0x40, 0xff),
+ ));
+ }
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("clip-path=\"url(#"),
+ "clipped frame must keep content clip: {svg}"
+ );
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "outer stroke must emit an evenodd outline: {svg}"
+ );
+ let stroke_pos = svg.find("fill-rule=\"evenodd\"").expect("stroke outline");
+ let clip_close = svg.find(" ").expect("clip group close");
+ assert!(
+ stroke_pos > clip_close,
+ "outer stroke must be outside the content clip group: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
#[test]
fn exports_text_with_multiple_solid_fills() {
let mut pool = ShapesPool::new();
@@ -240,6 +424,661 @@ fn exports_text_with_multiple_solid_fills() {
insta::assert_snapshot!(svg);
}
+#[test]
+fn exports_rect_with_solid_inner_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (10.0, 10.0, 110.0, 90.0),
+ solid_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
+ "inner stroke must emit a filled outline: {svg}"
+ );
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "aligned stroke outline should use evenodd: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_per_side_solid_inner_stroke() {
+ // Regression: solid Inner/Outer SVG expansion used stroke_to_path with a
+ // uniform width and ignored stroke.widths. Per-side must use the GPU
+ // evenodd band (top/right/bottom/left).
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let mut stroke = solid_stroke(
+ StrokeKind::Inner,
+ 20.0,
+ skia::Color::from_rgb(0x10, 0x40, 0xff),
+ );
+ stroke.widths = Some([4.0, 12.0, 24.0, 40.0]); // top, right, bottom, left
+ add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 100.0), stroke);
+ {
+ let shape = pool.get_mut(&id).unwrap();
+ shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
+ 0xff, 0xd4, 0x00,
+ )))]);
+ }
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "per-side inner stroke must emit an evenodd band: {svg}"
+ );
+ // Inner hole for (0,0)-(140,100) with [4,12,24,40]: (40,4)-(128,76).
+ // Uniform width=20 would incorrectly hole at (20,20)-(120,80).
+ assert!(
+ svg.contains("40") && svg.contains("128") && svg.contains("76"),
+ "per-side hole must reflect left=40 / right=12 / bottom=24, got: {svg}"
+ );
+ assert!(
+ !svg.contains("M20 20") && !svg.contains("L20 20"),
+ "must not use uniform width=20 inset: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_solid_center_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (10.0, 10.0, 110.0, 90.0),
+ solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""),
+ "center stroke must keep a stroke attribute: {svg}"
+ );
+ assert!(
+ !svg.contains("fill-rule=\"evenodd\""),
+ "center stroke must not expand to an evenodd outline: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_solid_outer_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (20.0, 20.0, 120.0, 100.0),
+ solid_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
+ "outer stroke must emit a filled outline: {svg}"
+ );
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "aligned stroke outline should use evenodd: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rotated_rect_with_solid_outer_stroke() {
+ // Regression: outline strokes must use local selrect geometry. Baking
+ // `centered_transform` into the path (via rect_segments) while the leaf
+ // canvas also concatenates it double-rotates the stroke.
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 140.0, 100.0),
+ solid_stroke(
+ StrokeKind::Outer,
+ 12.0,
+ skia::Color::from_rgb(0x10, 0x40, 0xff),
+ ),
+ );
+ {
+ let shape = pool.get_mut(&id).unwrap();
+ // 30° rotation (cos≈0.866, sin=0.5), matching a workspace export case.
+ let c = 0.866_025_4_f32;
+ let s = 0.5_f32;
+ shape.set_transform(c, s, -s, c, 0.0, 0.0);
+ shape.set_rotation(30.0);
+ shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
+ 0xff, 0xd4, 0x00,
+ )))]);
+ }
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "rotated outer stroke must emit an evenodd outline: {svg}"
+ );
+ // Fill rect + stroke path should both carry the same leaf CTM (one rotation).
+ assert!(
+ svg.matches("matrix(0.866025").count() >= 2,
+ "fill and stroke must each use the rotation matrix once: {svg}"
+ );
+ // Outline path data must stay in local selrect space (roughly [-stroke, w+stroke]).
+ // Double rotation bakes world-space points into `d` before the CTM is applied.
+ let d_attr = svg
+ .split("d=\"")
+ .nth(1)
+ .and_then(|s| s.split('"').next())
+ .unwrap_or("");
+ let first_num = d_attr
+ .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
+ .split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
+ .find(|s| !s.is_empty())
+ .and_then(|s| s.parse::().ok());
+ assert!(
+ matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)),
+ "stroke path d= should start in local coords, got {first_num:?} from {d_attr}: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_solid_inner_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
+ "closed path inner stroke must emit a filled outline: {svg}"
+ );
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "aligned stroke outline should use evenodd: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rotated_closed_path_with_solid_inner_stroke() {
+ // Regression: path content is stored in parent space; stroke outlines must
+ // apply `to_path_transform` (like fills via get_skia_path) before drawing
+ // under the leaf `centered_transform`, or the stroke double-rotates.
+ use crate::shapes::Type;
+
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 140.0, 100.0),
+ solid_stroke(
+ StrokeKind::Inner,
+ 12.0,
+ skia::Color::from_rgb(0x10, 0x40, 0xff),
+ ),
+ );
+ {
+ let shape = pool.get_mut(&id).unwrap();
+ let c = 0.866_025_4_f32;
+ let s = 0.5_f32;
+ shape.set_transform(c, s, -s, c, 0.0, 0.0);
+ shape.set_rotation(30.0);
+ shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
+ 0xff, 0xd4, 0x00,
+ )))]);
+
+ // Bake rotation into path points (Penpot path storage model).
+ let bake = shape.centered_transform();
+ if let Type::Path(ref mut path) = shape.shape_type {
+ path.transform(&bake);
+ let b = path.bounds();
+ shape.set_selrect(b.min_x(), b.min_y(), b.max_x(), b.max_y());
+ }
+ }
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "rotated path inner stroke must emit an evenodd outline: {svg}"
+ );
+ assert!(
+ svg.matches("matrix(0.866025").count() >= 2,
+ "fill and stroke must each use the rotation matrix once: {svg}"
+ );
+ // Stroke outline `d` must stay in local (unrotated) space like the fill.
+ let stroke_d = svg
+ .split("fill-rule=\"evenodd\"")
+ .next()
+ .and_then(|before| before.rsplit("d=\"").next())
+ .and_then(|s| s.split('"').next())
+ .unwrap_or("");
+ let first_num = stroke_d
+ .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
+ .split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.')
+ .find(|s| !s.is_empty())
+ .and_then(|s| s.parse::().ok());
+ assert!(
+ matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)),
+ "stroke path d= should start in local coords, got {first_num:?} from {stroke_d}: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_solid_center_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""),
+ "closed path center stroke must keep a stroke attribute: {svg}"
+ );
+ assert!(
+ !svg.contains("fill-rule=\"evenodd\""),
+ "center stroke must not expand to an evenodd outline: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_solid_outer_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)),
+ );
+
+ let svg = render(&pool, id);
+ // Path outer previously used save_layer+Clear (dropped by SkSVG). Must not
+ // be a bare stroked path with no visible paint.
+ assert!(
+ svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
+ "closed path outer stroke must emit a filled outline: {svg}"
+ );
+ assert!(
+ svg.contains("fill-rule=\"evenodd\""),
+ "aligned stroke outline should use evenodd: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_solid_inner_stroke() {
+ // Open paths force Center alignment regardless of the requested kind.
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_open_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""),
+ "open path inner stroke must render as center stroke: {svg}"
+ );
+ assert!(
+ !svg.contains("fill-rule=\"evenodd\""),
+ "open path must not expand to an evenodd outline: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_solid_center_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_open_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("stroke=\"red\"") || svg.to_ascii_lowercase().contains("stroke=\"#ff0000\""),
+ "open path center stroke must keep a stroke attribute: {svg}"
+ );
+ assert!(
+ !svg.contains("fill-rule=\"evenodd\""),
+ "open path must not expand to an evenodd outline: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_solid_outer_stroke() {
+ // Open paths force Center alignment regardless of the requested kind.
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_open_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""),
+ "open path outer stroke must render as center stroke: {svg}"
+ );
+ assert!(
+ !svg.contains("fill-rule=\"evenodd\""),
+ "open path must not expand to an evenodd outline: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_dotted_inner_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (10.0, 10.0, 110.0, 90.0),
+ dotted_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
+ "dotted inner stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_dotted_center_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (10.0, 10.0, 110.0, 90.0),
+ dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ // PathEffect does not serialize; dots expand to filled outline geometry.
+ assert!(
+ svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
+ "dotted center stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_dotted_outer_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (20.0, 20.0, 120.0, 100.0),
+ dotted_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
+ "dotted outer stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_dotted_inner_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ dotted_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""),
+ "closed path dotted inner stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_dotted_center_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
+ "closed path dotted center stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_dotted_outer_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ dotted_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""),
+ "closed path dotted outer stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_dotted_center_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_stroked_open_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)),
+ );
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""),
+ "open path dotted stroke must emit filled geometry: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_dotted_stroke_and_caps() {
+ // Regression: dotted/dashed SVG expansion used stroke_to_path and returned
+ // before draw_stroke_geometry, so open-path caps (triangle/circle/…) were
+ // dropped. Caps must be overlaid after the expanded outline.
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let mut stroke = dotted_stroke(
+ StrokeKind::Center,
+ 12.0,
+ skia::Color::from_rgb(0x10, 0x40, 0xff),
+ );
+ stroke.cap_start = Some(StrokeCap::TriangleArrow);
+ stroke.cap_end = Some(StrokeCap::CircleMarker);
+ add_stroked_open_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 90.0), stroke);
+
+ let svg = render(&pool, id);
+ assert!(
+ svg.contains("fill=\"#1040FF\"") || svg.to_ascii_lowercase().contains("fill=\"#1040ff\""),
+ "dotted stroke with caps must emit filled geometry: {svg}"
+ );
+ // Caps are separate filled draws (triangle + circle), not only the dotted outline.
+ assert!(
+ svg.matches("= 2 || svg.contains(": {svg}"
+ );
+ assert!(
+ svg.contains("clip-path=\"url(#"),
+ "text image fill must be clipped to glyph silhouette: {svg}"
+ );
+ assert!(
+ svg.contains(" element: {svg}"
+ );
+ assert!(
+ svg.contains(TEST_IMAGE_URL),
+ "image href must use the registered URL: {svg}"
+ );
+ assert!(
+ svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
+ "keep-aspect image fill must slice: {svg}"
+ );
+ assert!(
+ svg.contains("clip-path=\"url(#"),
+ "image fill must be clipped to shape geometry: {svg}"
+ );
+ assert!(
+ !svg.contains("data:image"),
+ "must not base64-embed the image: {svg}"
+ );
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_image_fill_bounds_transform() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_rect_with_fills(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ vec![Fill::Image(ImageFill::new_with_transform(
+ image_id,
+ 255,
+ 200,
+ 100,
+ false,
+ Some(ImageFillTransform {
+ x: 0.25,
+ y: 0.5,
+ width: 0.5,
+ height: 0.25,
+ }),
+ ))],
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert!(
+ svg.contains(r#"x="25" y="40" width="50" height="20""#),
+ "linked image must keep the independent image bounds: {svg}"
+ );
+}
+
+#[test]
+fn exports_mixed_solid_and_image_fills_in_order() {
+ // Image under a translucent solid; stretch (keep-aspect off); partial image
+ // opacity; shape not at the page origin (page translate in CTM).
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_rect_with_fills(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (100.0, 50.0, 508.0, 178.0),
+ vec![
+ // fills[0] topmost — solid blue @ 50%
+ Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
+ // fills[1] underneath — linked image, stretch, ~50% opacity
+ Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
+ ],
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ let image_pos = svg.find(": {svg}"
+ );
+ assert!(
+ svg.contains("imgstrokeclip") && svg.contains("clip-path=\"url(#"),
+ "image stroke must clip to the stroke outline: {svg}"
+ );
+ assert!(
+ !svg.contains("data:image"),
+ "must not base64-embed the stroke image: {svg}"
+ );
+}
+
+fn assert_evenodd_stroke_clip(svg: &str) {
+ assert!(
+ svg.contains("clip-rule=\"evenodd\""),
+ "stroke clip must use clip-rule=evenodd: {svg}"
+ );
+ assert!(
+ !svg.contains("fill-rule=\"evenodd\""),
+ "clipPath should rewrite fill-rule to clip-rule: {svg}"
+ );
+}
+
+#[test]
+fn exports_rect_with_solid_center_image_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (10.0, 10.0, 110.0, 90.0),
+ image_solid_stroke(StrokeKind::Center, 8.0, image_id),
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_solid_inner_image_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ image_solid_stroke(StrokeKind::Inner, 10.0, image_id),
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_solid_outer_image_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (20.0, 20.0, 120.0, 100.0),
+ image_solid_stroke(StrokeKind::Outer, 10.0, image_id),
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_rect_with_dotted_center_image_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_stroked_rect(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (10.0, 10.0, 110.0, 90.0),
+ image_dotted_stroke(StrokeKind::Center, 8.0, image_id),
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_closed_path_with_solid_outer_image_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_stroked_closed_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ image_solid_stroke(StrokeKind::Outer, 8.0, image_id),
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_solid_center_image_stroke() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ add_stroked_open_path(
+ &mut pool,
+ id,
+ Uuid::nil(),
+ (0.0, 0.0, 100.0, 80.0),
+ image_solid_stroke(StrokeKind::Center, 8.0, image_id),
+ );
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ insta::assert_snapshot!(svg);
+}
+
+#[test]
+fn exports_open_path_with_image_stroke_and_caps() {
+ // Caps go into the clip silhouette with the outline. Image dest must grow
+ // past stroke.delta() so triangle/circle markers stay textured.
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ let image_id = uid(42);
+ let mut stroke = image_solid_stroke(StrokeKind::Center, 12.0, image_id);
+ stroke.cap_start = Some(StrokeCap::TriangleArrow);
+ stroke.cap_end = Some(StrokeCap::CircleMarker);
+ add_stroked_open_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 90.0), stroke);
+
+ let svg = render_with(&pool, id, |resources| {
+ resources
+ .images
+ .set_source_url(image_id, TEST_IMAGE_URL.to_string());
+ });
+
+ assert_linked_image_stroke(&svg);
+ assert_evenodd_stroke_clip(&svg);
+ let clip = svg
+ .split("").next())
+ .expect("imgstroke clipPath");
+ assert!(
+ clip.matches("= 2
+ || clip.contains("` elements.
+/// Emits a text shape's fills for SVG export.
///
-/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
-/// silently drops. Text strokes are handled separately in a later PR.
-pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
+/// Linked image fills become `` clipped to the glyph silhouette;
+/// other fills go through Skia as native ``. Strokes are a later PR.
+pub(super) fn render_text_fill(
+ builder: &mut SvgLayerCanvas,
+ shared: &RenderResources,
+ element: &Shape,
+) -> Result<()> {
+ let text_content = element.get_text_content();
+ let text_content = text_content.new_bounds(element.selrect());
+ let max_layers = text_content.max_fill_layers();
+ if max_layers == 0 {
+ return Ok(());
+ }
+
let matrix = element.centered_transform();
- let canvas = builder.canvas();
- canvas.save();
- canvas.concat(&matrix);
- text::paint_text_fill(canvas, element);
- canvas.restore();
+
+ for layer in 0..max_layers {
+ let linked = linked_image_fills_at_layer(&text_content, layer, shared);
+ let skip_ids: HashSet = linked.iter().map(|img| img.id()).collect();
+
+ for image_fill in &linked {
+ emit_text_image_fill(builder, shared, element, image_fill, layer)?;
+ }
+
+ if layer_has_skia_fills(&text_content, layer, &skip_ids) {
+ let mut paragraph_builders = if skip_ids.is_empty() {
+ text_content.paragraph_builder_group_for_fill_layer(layer)
+ } else {
+ text_content
+ .paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids)
+ };
+ let canvas = builder.canvas();
+ canvas.save();
+ canvas.concat(&matrix);
+ text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
+ canvas.restore();
+ }
+ }
+
+ Ok(())
+}
+
+fn linked_image_fills_at_layer<'a>(
+ text_content: &'a crate::shapes::TextContent,
+ layer: usize,
+ shared: &RenderResources,
+) -> Vec<&'a ImageFill> {
+ let mut out = Vec::new();
+ let mut seen = HashSet::new();
+ for paragraph in text_content.paragraphs() {
+ for span in paragraph.children() {
+ if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) {
+ if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) {
+ out.push(img);
+ }
+ }
+ }
+ }
+ out
+}
+
+fn layer_has_skia_fills(
+ text_content: &crate::shapes::TextContent,
+ layer: usize,
+ skip_ids: &HashSet,
+) -> bool {
+ text_content.paragraphs().iter().any(|paragraph| {
+ paragraph
+ .children()
+ .iter()
+ .any(|span| match span.fills_from_bottom(layer) {
+ Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false,
+ Some(_) => true,
+ None => false,
+ })
+ })
+}
+
+/// Linked `` clipped to the opaque glyph silhouette for this image layer.
+fn emit_text_image_fill(
+ builder: &mut SvgLayerCanvas,
+ shared: &RenderResources,
+ shape: &Shape,
+ image_fill: &ImageFill,
+ layer: usize,
+) -> Result<()> {
+ let Some(url) = shared.images.source_url(&image_fill.id()) else {
+ return Ok(());
+ };
+
+ let clip_id = builder.unique("txtimgclip");
+ let text_content = shape.get_text_content().new_bounds(shape.selrect());
+ let mut paragraph_builders =
+ text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id());
+
+ let canvas = builder.new_fragment();
+ {
+ let cv: &skia_safe::Canvas = &canvas;
+ cv.save();
+ cv.concat(&shape.centered_transform());
+ text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
+ cv.restore();
+ }
+ builder.finish_clip_path_fragment(&clip_id, canvas);
+
+ let href = xml_escape_attr(url);
+ emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
Ok(())
}
diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs
index ef41565af7..b2948f823e 100644
--- a/render-wasm/src/render/text.rs
+++ b/render-wasm/src/render/text.rs
@@ -3,15 +3,16 @@ use crate::{
error::Result,
math::Rect,
shapes::{
- add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
- ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign,
+ add_text_with_tabs, calculate_text_layout_data, set_paint_fill, Paragraph as TextParagraph,
+ ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
+ TextDecorationSegment, VerticalAlign,
},
utils::{get_fallback_fonts, get_font_collection},
};
use skia_safe::{
self as skia,
canvas::SaveLayerRec,
- textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle},
+ textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration},
Canvas, ImageFilter, Paint,
};
@@ -374,85 +375,22 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
};
let mut y_accum = base_y + vertical_offset;
- for group in paragraphs.iter() {
+ for (index, group) in paragraphs.iter().enumerate() {
let Some(paragraph) = group.first() else {
continue;
};
paragraph.paint(canvas, (x, y_accum));
if draw_decorations {
- paint_decorations_for_paragraph(canvas, paragraph, x, y_accum);
+ if let Some(text_paragraph) = text_content.paragraphs().get(index) {
+ for deco in decoration_segments(paragraph, text_paragraph, x, y_accum) {
+ draw_decoration_segment(canvas, &deco);
+ }
+ }
}
y_accum += paragraph.height();
}
}
-fn paint_decorations_for_paragraph(
- canvas: &Canvas,
- paragraph: &skia::textlayout::Paragraph,
- x: f32,
- y_accum: f32,
-) {
- let line_metrics = paragraph.get_line_metrics();
- for line in &line_metrics {
- let style_metrics: Vec<_> = line
- .get_style_metrics(line.start_index..line.end_index)
- .into_iter()
- .collect();
- let line_baseline = y_accum + line.baseline as f32;
- let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
- calculate_decoration_metrics(&style_metrics, line_baseline);
- for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
- let text_style = &style_metric.text_style;
- let style_end = style_metrics
- .get(i + 1)
- .map(|(next_i, _)| *next_i)
- .unwrap_or(line.end_index);
- let seg_start = (*style_start).max(line.start_index);
- let seg_end = style_end.min(line.end_index);
- if seg_start >= seg_end {
- continue;
- }
- let rects = paragraph.get_rects_for_range(
- seg_start..seg_end,
- skia::textlayout::RectHeightStyle::Tight,
- skia::textlayout::RectWidthStyle::Tight,
- );
- let (segment_width, actual_x_offset) = if !rects.is_empty() {
- let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
- let skia_x_offset = rects
- .first()
- .map(|r| r.rect.left - line.left as f32)
- .unwrap_or(0.0);
- (total_width, skia_x_offset)
- } else {
- (0.0, 0.0)
- };
- let text_left = x + line.left as f32 + actual_x_offset;
- let text_width = segment_width;
- if text_style.decoration().ty == TextDecoration::UNDERLINE {
- draw_text_decorations(
- canvas,
- text_style,
- Some(underline_y.unwrap_or(line_baseline)),
- max_underline_thickness,
- text_left,
- text_width,
- );
- }
- if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
- draw_text_decorations(
- canvas,
- text_style,
- Some(strike_y.unwrap_or(line_baseline)),
- max_strike_thickness,
- text_left,
- text_width,
- );
- }
- }
- }
-}
-
#[allow(clippy::too_many_arguments)]
fn render_text_on_canvas(
canvas: &Canvas,
@@ -530,22 +468,13 @@ fn render_text_on_canvas(
}
}
-/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
-/// `SkSVGDevice` would drop.
-pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
- let text_content = shape.get_text_content();
- let text_content = text_content.new_bounds(shape.selrect());
- let max_layers = text_content.max_fill_layers();
- if max_layers == 0 {
- return;
- }
-
- // Each fill layer is painted separately so SkSVGDevice can emit `fill`
- // attributes (merged shaders are dropped). Bottom layer first.
- for layer in 0..max_layers {
- let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
- paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
- }
+/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
+pub fn paint_text_paragraphs(
+ canvas: &Canvas,
+ shape: &Shape,
+ paragraph_builder_groups: &mut [Vec],
+) {
+ paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
}
/// Lays out and paints paragraph builders without any layer management.
@@ -593,14 +522,7 @@ fn paint_text_with_emoji_overlay(
}
for deco in ¶.decorations {
- draw_text_decorations(
- canvas,
- &deco.text_style,
- Some(deco.y),
- deco.thickness,
- deco.left,
- deco.width,
- );
+ draw_decoration_segment(canvas, deco);
}
}
}
@@ -820,17 +742,9 @@ fn paint_emoji_opaque(
.paint(canvas, (emoji_para.x, emoji_para.y));
for deco in &deco_para.decorations {
- draw_text_decorations(
- canvas,
- &deco.text_style,
- Some(deco.y),
- deco.thickness,
- deco.left,
- deco.width,
- );
- let r = decoration_rect(deco.y, deco.thickness, deco.left, deco.width);
+ draw_decoration_segment(canvas, deco);
for (kind, paint) in stroke_decos {
- draw_decoration_stroke(canvas, *kind, paint, r);
+ draw_decoration_stroke(canvas, *kind, paint, deco.rect());
}
}
canvas.restore();
@@ -1143,40 +1057,128 @@ pub fn render_outer_stroke(
)
}
-fn decoration_rect(y: f32, thickness: f32, text_left: f32, text_width: f32) -> skia_safe::Rect {
- skia_safe::Rect::new(
- text_left,
- y - thickness / 2.0,
- text_left + text_width,
- y + thickness / 2.0,
- )
+fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) {
+ let mut decoration_paint = deco.text_style.foreground();
+ decoration_paint.set_anti_alias(true);
+ canvas.draw_rect(deco.rect(), &decoration_paint);
}
-fn draw_text_decorations(
- canvas: &Canvas,
- text_style: &TextStyle,
- y: Option,
- thickness: f32,
- text_left: f32,
- text_width: f32,
-) {
- if let Some(y) = y {
- let r = decoration_rect(y, thickness, text_left, text_width);
- let mut decoration_paint = text_style.foreground();
- decoration_paint.set_anti_alias(true);
- canvas.draw_rect(r, &decoration_paint);
+/// One decorated span clipped to a line: UTF-16 range, decoration and the
+/// Skia style run it falls in (paint + font metrics).
+type LineDecoration<'a> = (usize, usize, TextDecoration, &'a StyleMetrics<'a>);
+
+/// UTF-16 ranges of the spans that ask for a decoration we draw.
+fn decorated_span_ranges(text_paragraph: &TextParagraph) -> Vec<(usize, usize, TextDecoration)> {
+ let mut ranges = Vec::new();
+ let mut offset = 0;
+ for span in text_paragraph.children() {
+ let len = span.apply_text_transform().encode_utf16().count();
+ match span.text_decoration {
+ Some(kind)
+ if kind == TextDecoration::UNDERLINE || kind == TextDecoration::LINE_THROUGH =>
+ {
+ ranges.push((offset, offset + len, kind))
+ }
+ _ => {}
+ }
+ offset += len;
}
+ ranges
}
-pub fn calculate_decoration_metrics(
- style_metrics: &Vec<(usize, &StyleMetrics)>,
+/// Style run covering `offset`; runs are keyed by their start index.
+fn style_metric_at<'a>(
+ style_metrics: &[(usize, &'a StyleMetrics<'a>)],
+ offset: usize,
+) -> Option<&'a StyleMetrics<'a>> {
+ style_metrics
+ .iter()
+ .rev()
+ .find(|(start, _)| *start <= offset)
+ .map(|(_, metrics)| *metrics)
+}
+
+/// Decoration bars for one laid out paragraph, in shape coordinates.
+///
+/// Segmented by the model's spans, so which spans get a bar never depends on
+/// how Skia grouped the line into style runs; the runs only supply the paint
+/// and font metrics covering each segment.
+pub fn decoration_segments(
+ skia_paragraph: &skia::textlayout::Paragraph,
+ text_paragraph: &TextParagraph,
+ x: f32,
+ y_accum: f32,
+) -> Vec {
+ let decorated = decorated_span_ranges(text_paragraph);
+ if decorated.is_empty() {
+ return Vec::new();
+ }
+
+ let mut segments = Vec::new();
+ for line in &skia_paragraph.get_line_metrics() {
+ let style_metrics: Vec<_> = line
+ .get_style_metrics(line.start_index..line.end_index)
+ .into_iter()
+ .collect();
+ let line_baseline = y_accum + line.baseline as f32;
+
+ let line_decorations: Vec> = decorated
+ .iter()
+ .filter_map(|&(start, end, kind)| {
+ let seg_start = start.max(line.start_index);
+ let seg_end = end.min(line.end_index);
+ if seg_start >= seg_end {
+ return None;
+ }
+ let metrics = style_metric_at(&style_metrics, seg_start)?;
+ Some((seg_start, seg_end, kind, metrics))
+ })
+ .collect();
+
+ let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
+ calculate_decoration_metrics(&line_decorations, line_baseline);
+
+ for (seg_start, seg_end, kind, metrics) in line_decorations {
+ let rects = skia_paragraph.get_rects_for_range(
+ seg_start..seg_end,
+ skia::textlayout::RectHeightStyle::Tight,
+ skia::textlayout::RectWidthStyle::Tight,
+ );
+ let (width, x_offset) = match rects.first() {
+ Some(first) => {
+ let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
+ (total_width, first.rect.left - line.left as f32)
+ }
+ None => (0.0, 0.0),
+ };
+ let (y, thickness) = if kind == TextDecoration::LINE_THROUGH {
+ (strike_y, max_strike_thickness)
+ } else {
+ (underline_y, max_underline_thickness)
+ };
+ segments.push(TextDecorationSegment {
+ kind,
+ text_style: (*metrics.text_style).clone(),
+ y: y.unwrap_or(line_baseline),
+ thickness,
+ left: x + line.left as f32 + x_offset,
+ width,
+ });
+ }
+ }
+
+ segments
+}
+
+fn calculate_decoration_metrics(
+ line_decorations: &[LineDecoration<'_>],
line_baseline: f32,
) -> (f32, Option, f32, Option) {
let mut max_underline_thickness: f32 = 0.0;
let mut underline_y = None;
let mut max_strike_thickness: f32 = 0.0;
let mut strike_y = None;
- for (_style_start, style_metric) in style_metrics.iter() {
+ for (_seg_start, _seg_end, kind, style_metric) in line_decorations.iter() {
let font_metrics = style_metric.font_metrics;
let font_size = font_metrics
.cap_height
@@ -1192,7 +1194,7 @@ pub fn calculate_decoration_metrics(
let thickness = (font_metrics.underline_thickness().unwrap_or(1.0) * thickness_factor)
.max(min_thickness);
- if style_metric.text_style.decoration().ty == TextDecoration::UNDERLINE {
+ if *kind == TextDecoration::UNDERLINE {
// Same gap from baseline to underline as in Chromium
// (see https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/render_text.cc
let gap_scaling = raw_font_size * 1.0 / 9.0;
@@ -1201,7 +1203,7 @@ pub fn calculate_decoration_metrics(
max_underline_thickness = max_underline_thickness.max(thickness);
underline_y = Some(y);
}
- if style_metric.text_style.decoration().ty == TextDecoration::LINE_THROUGH {
+ if *kind == TextDecoration::LINE_THROUGH {
let y = line_baseline
+ font_metrics
.strikeout_position()
@@ -1243,3 +1245,92 @@ pub fn calculate_decoration_metrics(
// shadows::render_text_inner_shadows(self, &shape, &paths, antialias);
// }
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextSpan, TextTransform};
+ use crate::uuid::Uuid;
+
+ fn span(
+ text: &str,
+ decoration: Option,
+ transform: Option,
+ ) -> TextSpan {
+ TextSpan::new(
+ text.to_string(),
+ FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
+ 14.0,
+ 1.2,
+ 0.0,
+ decoration,
+ transform,
+ TextDirection::LTR,
+ 400,
+ Uuid::nil(),
+ vec![],
+ )
+ }
+
+ fn paragraph(spans: Vec) -> TextParagraph {
+ TextParagraph::new(
+ TextAlign::Left,
+ TextDirection::LTR,
+ None,
+ None,
+ 1.2,
+ 0.0,
+ spans,
+ )
+ }
+
+ #[test]
+ fn decorated_ranges_follow_spans_not_paint_runs() {
+ let para = paragraph(vec![
+ span("plain ", None, None),
+ span("under", Some(TextDecoration::UNDERLINE), None),
+ span(" plain ", None, None),
+ span("struck", Some(TextDecoration::LINE_THROUGH), None),
+ ]);
+
+ assert_eq!(
+ decorated_span_ranges(¶),
+ vec![
+ (6, 11, TextDecoration::UNDERLINE),
+ (18, 24, TextDecoration::LINE_THROUGH),
+ ]
+ );
+ }
+
+ #[test]
+ fn decorated_ranges_are_utf16_offsets_of_the_transformed_text() {
+ let para = paragraph(vec![
+ span("🎉", None, None),
+ span(
+ "straße",
+ Some(TextDecoration::UNDERLINE),
+ Some(TextTransform::Uppercase),
+ ),
+ span("x", Some(TextDecoration::UNDERLINE), None),
+ ]);
+
+ // The emoji takes two UTF-16 units and `ß` uppercases to `SS`.
+ assert_eq!(
+ decorated_span_ranges(¶),
+ vec![
+ (2, 9, TextDecoration::UNDERLINE),
+ (9, 10, TextDecoration::UNDERLINE),
+ ]
+ );
+ }
+
+ #[test]
+ fn undecorated_paragraphs_have_no_ranges() {
+ let para = paragraph(vec![
+ span("plain", None, None),
+ span("none", Some(TextDecoration::NO_DECORATION), None),
+ ]);
+
+ assert!(decorated_span_ranges(¶).is_empty());
+ }
+}
diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs
index b19117b9d4..f12f134489 100644
--- a/render-wasm/src/render/vector.rs
+++ b/render-wasm/src/render/vector.rs
@@ -2,7 +2,8 @@ use skia_safe::{self as skia, Canvas, Paint, RRect};
use crate::error::Result;
use crate::shapes::{
- merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type,
+ circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path,
+ BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type,
};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
@@ -11,7 +12,7 @@ use super::shape_renderer::ShapeRenderer;
use super::text;
use super::RenderResources;
use super::RenderState;
-use super::{get_dest_rect, get_source_rect};
+use super::{get_dest_rect, get_image_dest_rect, get_source_rect};
// ---------------------------------------------------------------------------
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
@@ -22,9 +23,10 @@ pub(super) struct VectorRenderer<'a> {
canvas: &'a Canvas,
shared: &'a mut RenderResources,
scale: f32,
- /// When `true`, multiple fills are composited into a single shader (PDF).
- /// When `false`, each fill is drawn separately so SkSVGDevice can emit
- /// `fill` attributes (SVG export).
+ /// When `true`, use PDF/GPU-friendly compositing (`merge_fills`,
+ /// `save_layer` for outer strokes). When `false` (SVG export), avoid
+ /// techniques that `SkSVGDevice` drops: draw fills individually and emit
+ /// solid Inner/Outer strokes as filled outlines.
compose_fills: bool,
}
@@ -42,6 +44,18 @@ impl<'a> VectorRenderer<'a> {
compose_fills,
}
}
+
+ /// Layer-blur paint filter for this backend.
+ ///
+ /// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops
+ /// paint image-filters (the shape would vanish). Layer blur is re-emitted as
+ /// a native SVG `` wrapper instead.
+ fn layer_blur_filter(&self, shape: &Shape) -> Option {
+ if !self.compose_fills {
+ return None;
+ }
+ shape.image_filter(1.)
+ }
}
impl ShapeRenderer for VectorRenderer<'_> {
@@ -50,17 +64,24 @@ impl ShapeRenderer for VectorRenderer<'_> {
return Ok(());
}
+ let blur_filter = self.layer_blur_filter(shape);
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if !self.compose_fills || has_image_fills {
// fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG).
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) => {
- draw_image_fill(self.shared, self.canvas, shape, image_fill)?;
+ draw_image_fill(
+ self.shared,
+ self.canvas,
+ shape,
+ image_fill,
+ blur_filter.as_ref(),
+ )?;
}
_ => {
let mut paint = fill.to_paint(&shape.selrect, true);
- if let Some(filter) = shape.image_filter(1.) {
+ if let Some(filter) = blur_filter.clone() {
paint.set_image_filter(filter);
}
draw_shape_geometry(self.canvas, shape, &paint);
@@ -73,7 +94,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(true);
- if let Some(filter) = shape.image_filter(1.) {
+ if let Some(filter) = blur_filter {
paint.set_image_filter(filter);
}
@@ -82,8 +103,16 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> {
+ let svg_export = !self.compose_fills;
for stroke in strokes.iter().rev() {
- draw_single_stroke(self.canvas, self.shared, self.scale, shape, stroke)?;
+ draw_single_stroke(
+ self.canvas,
+ self.shared,
+ self.scale,
+ shape,
+ stroke,
+ svg_export,
+ )?;
}
Ok(())
}
@@ -114,7 +143,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
let layer_bounds = shape.layer_bounds();
for shadow in shape.inner_shadows_visible() {
- let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
+ let paint = shadow.get_inner_shadow_paint(true, self.layer_blur_filter(shape).as_ref());
self.canvas.save_layer(
&skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
@@ -153,7 +182,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
- let blur_filter = shape.image_filter(1.);
+ let blur_filter = self.layer_blur_filter(shape);
// Text drop shadows: one filter layer per shadow over fill + stroke
// silhouettes (mirrors GPU `render_text_shadows`).
@@ -343,6 +372,9 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
fn apply_blur_layer(&mut self, shape: &Shape) -> bool {
+ if !self.compose_fills {
+ return false;
+ }
let blur = match shape.blur {
Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b,
_ => return false,
@@ -840,9 +872,9 @@ fn render_frame(
canvas.save_layer(&layer_rec);
}
- // Clip to frame bounds in the frame's own space, then undo the transform so
- // children draw at their absolute coords while staying clipped (mirrors the
- // GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam.
+ // Clip fills + children only. Strokes render outside the content clip so
+ // outer/center strokes are not trimmed (same as GPU render_shape_exit).
+ canvas.save();
if element.clip_content {
canvas.concat(&matrix);
clip_to_frame_content(canvas, element, scale);
@@ -866,8 +898,9 @@ fn render_frame(
for child_id in &children {
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
}
+ canvas.restore(); // content clip
- // Strokes over children (clipped frames), in the frame's space.
+ // Strokes over children, outside the frame content clip.
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
canvas.save();
@@ -1022,6 +1055,7 @@ fn draw_image_fill(
canvas: &Canvas,
shape: &Shape,
image_fill: &crate::shapes::ImageFill,
+ blur_filter: Option<&skia::ImageFilter>,
) -> Result<()> {
// Use a CPU-backed image copy — GPU-backed images can't be drawn
// on the PDF canvas which has no GPU context.
@@ -1032,8 +1066,8 @@ fn draw_image_fill(
let size = image.dimensions();
let container = &shape.selrect;
- let src_rect = get_source_rect(size, container, image_fill);
- let dest_rect = container;
+ let dest_rect = get_image_dest_rect(container, image_fill);
+ let src_rect = get_source_rect(size, &dest_rect, image_fill);
canvas.save();
@@ -1042,8 +1076,8 @@ fn draw_image_fill(
let mut paint = Paint::default();
paint.set_anti_alias(true);
- if let Some(filter) = shape.image_filter(1.) {
- paint.set_image_filter(filter);
+ if let Some(filter) = blur_filter {
+ paint.set_image_filter(filter.clone());
}
canvas.draw_image_rect_with_sampling_options(
@@ -1064,16 +1098,186 @@ fn draw_single_stroke(
scale: f32,
shape: &Shape,
stroke: &Stroke,
+ svg_export: bool,
) -> Result<()> {
// Image-fill strokes: the stroke masks the visible area of the image.
if let Fill::Image(image_fill) = &stroke.fill {
return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill);
}
+ // Techniques SkSVGDevice cannot keep (save_layer+Clear/clip for Outer,
+ // PathEffect stamps for dots/dashes): expand to a filled outline instead.
+ // Solid Center stays on the shared stroke path.
+ if svg_export && draw_svg_stroke_as_fill(canvas, shape, stroke) {
+ return Ok(());
+ }
+
draw_stroke_geometry(canvas, scale, shape, stroke, false);
Ok(())
}
+/// Shape path in local coords for SVG stroke outline expansion.
+fn svg_stroke_shape_path(shape: &Shape) -> Option {
+ match &shape.shape_type {
+ Type::Rect(r) => Some(Path::new(rect_segments_local(shape, r.corners))),
+ Type::Frame(f) => Some(Path::new(rect_segments_local(shape, f.corners))),
+ Type::Circle => Some(Path::new(circle_segments_local(shape))),
+ Type::Path(_) | Type::Bool(_) => {
+ let path = shape.shape_type.path()?;
+ let mut local = path.clone();
+ if let Some(t) = shape.to_path_transform() {
+ local.transform(&t);
+ }
+ Some(local)
+ }
+ Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => None,
+ }
+}
+
+fn svg_stroke_solid_outline(stroke: &Stroke, is_open: bool) -> Option {
+ let kind = stroke.render_kind(is_open);
+ match stroke.style {
+ StrokeStyle::Solid => match kind {
+ // Solid Center already serializes as a native SVG stroke.
+ StrokeKind::Center => None,
+ StrokeKind::Inner | StrokeKind::Outer => {
+ if is_open {
+ None
+ } else {
+ Some(true)
+ }
+ }
+ },
+ // PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them.
+ StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => Some(false),
+ }
+}
+
+/// Draws a stroke as a filled path outline for SVG export.
+///
+/// Handles solid Inner/Outer and all dotted/dashed/mixed alignments (including
+/// Center and open paths, which force Center). Returns `true` when handled.
+fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> bool {
+ let is_open = shape.is_open();
+
+ // Per-side rect/frame strokes already expand to an evenodd band in
+ // `draw_stroke_on_rect`. `stroke_to_path` only knows a uniform width.
+ if stroke.per_side_widths().is_some()
+ && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
+ {
+ return false;
+ }
+
+ let Some(solid_outline) = svg_stroke_solid_outline(stroke, is_open) else {
+ return false;
+ };
+
+ let Some(shape_path) = svg_stroke_shape_path(shape) else {
+ return false;
+ };
+
+ let Some(outline) = stroke_to_path(
+ stroke,
+ &shape_path,
+ None,
+ &shape.selrect,
+ shape.svg_attrs.as_ref(),
+ solid_outline,
+ ) else {
+ return false;
+ };
+
+ let mut paint = stroke.fill.to_paint(&shape.selrect, true);
+ paint.set_style(skia::PaintStyle::Fill);
+ paint.set_anti_alias(true);
+ canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
+
+ // Expanded dotted/dashed strokes skip `draw_stroke_geometry`, which is
+ // where open-path caps are drawn. Overlay them here in local path space
+ // (same as fills / the outline above under the leaf CTM).
+ if is_open {
+ paint_svg_stroke_caps(canvas, shape, stroke, false);
+ }
+
+ true
+}
+
+/// Opaque stroke region for SVG clipPath silhouettes.
+///
+/// Expands every alignment (including solid Center) to a filled outline so we
+/// do not rely on save_layer + SrcIn. Returns false when there is nothing to draw.
+pub(super) fn paint_svg_stroke_silhouette(
+ canvas: &Canvas,
+ shape: &Shape,
+ stroke: &Stroke,
+ scale: f32,
+) -> bool {
+ let is_open = shape.is_open();
+
+ if stroke.per_side_widths().is_some()
+ && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
+ {
+ let corners = shape.shape_type.corners();
+ let mut paint = stroke.to_paint(&shape.selrect, shape.svg_attrs.as_ref(), true);
+ paint.set_shader(None);
+ paint.set_color(skia::Color::BLACK);
+ super::strokes::draw_stroke_on_rect(
+ canvas,
+ stroke,
+ &shape.selrect,
+ &corners,
+ &paint,
+ scale,
+ None,
+ None,
+ true,
+ );
+ return true;
+ }
+
+ let Some(shape_path) = svg_stroke_shape_path(shape) else {
+ return false;
+ };
+
+ // Expand Center too: a native stroke attribute cannot clip an image.
+ let solid_outline = matches!(stroke.style, StrokeStyle::Solid);
+ let Some(outline) = stroke_to_path(
+ stroke,
+ &shape_path,
+ None,
+ &shape.selrect,
+ shape.svg_attrs.as_ref(),
+ solid_outline,
+ ) else {
+ return false;
+ };
+
+ let mut paint = Paint::default();
+ paint.set_style(skia::PaintStyle::Fill);
+ paint.set_anti_alias(true);
+ paint.set_color(skia::Color::BLACK);
+ canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
+
+ if is_open {
+ paint_svg_stroke_caps(canvas, shape, stroke, true);
+ }
+
+ true
+}
+
+fn paint_svg_stroke_caps(canvas: &Canvas, shape: &Shape, stroke: &Stroke, opaque: bool) {
+ let Some(cap_path) = transformed_skia_path(shape) else {
+ return;
+ };
+ let mut cap_paint =
+ stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true);
+ if opaque {
+ cap_paint.set_shader(None);
+ cap_paint.set_color(skia::Color::BLACK);
+ }
+ super::strokes::handle_stroke_caps(&cap_path, stroke, canvas, true, &cap_paint, None, true);
+}
+
/// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle
/// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width
/// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette.
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 2db654e799..bb3c74f7f0 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -652,6 +652,13 @@ impl Shape {
self.background_blur.filter(|blur| !blur.hidden)
}
+ /// Visible layer blur (`!hidden`, `LayerBlur`, `value > 0`).
+ pub fn visible_layer_blur(&self) -> Option {
+ self.blur.filter(|blur| {
+ !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
+ })
+ }
+
#[cfg(test)]
pub fn add_child(&mut self, id: Uuid) {
self.children.push(id);
@@ -2059,6 +2066,51 @@ mod tests {
assert_eq!(shape.visible_background_blur(), None);
}
+ /// Cases mirrored from Penpot MCP board `layer-blur-cases` (file "blur"):
+ /// leaf-visible-blur, leaf-hidden-blur, leaf-zero-blur, leaf-background-blur,
+ /// group-with-blur.
+ #[test]
+ fn visible_layer_blur_requires_non_hidden_positive_layer_blur() {
+ let mut shape = any_shape();
+
+ // leaf-visible-blur / group-with-blur
+ let visible = Blur::new(BlurType::LayerBlur, false, 10.0);
+ shape.set_blur(Some(visible));
+ assert_eq!(shape.visible_layer_blur(), Some(visible));
+
+ let group_blur = Blur::new(BlurType::LayerBlur, false, 6.0);
+ shape.set_blur(Some(group_blur));
+ assert_eq!(shape.visible_layer_blur(), Some(group_blur));
+
+ // leaf-hidden-blur
+ shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
+ assert_eq!(shape.visible_layer_blur(), None);
+
+ // leaf-zero-blur
+ shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 0.0)));
+ assert_eq!(shape.visible_layer_blur(), None);
+
+ // no blur
+ shape.set_blur(None);
+ assert_eq!(shape.visible_layer_blur(), None);
+ }
+
+ #[test]
+ fn visible_layer_blur_ignores_background_blur() {
+ let mut shape = any_shape();
+ // leaf-background-blur: Plugin API uses `backgroundBlur`, not `blur`.
+ shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)));
+ assert_eq!(shape.visible_layer_blur(), None);
+ assert_eq!(
+ shape.visible_background_blur(),
+ Some(Blur::new(BlurType::BackgroundBlur, false, 8.0))
+ );
+
+ let layer = Blur::new(BlurType::LayerBlur, false, 4.0);
+ shape.set_blur(Some(layer));
+ assert_eq!(shape.visible_layer_blur(), Some(layer));
+ }
+
#[test]
fn test_set_corners() {
let mut shape = any_shape();
diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs
index 15d5dc09f7..1faeb54185 100644
--- a/render-wasm/src/shapes/fills.rs
+++ b/render-wasm/src/shapes/fills.rs
@@ -118,6 +118,14 @@ impl Gradient {
}
}
+#[derive(Debug, Clone, PartialEq, Copy)]
+pub struct ImageFillTransform {
+ pub x: f32,
+ pub y: f32,
+ pub width: f32,
+ pub height: f32,
+}
+
#[derive(Debug, Clone, PartialEq)]
pub struct ImageFill {
id: Uuid,
@@ -125,6 +133,7 @@ pub struct ImageFill {
width: i32,
height: i32,
keep_aspect_ratio: bool,
+ transform: Option,
}
impl ImageFill {
@@ -135,6 +144,25 @@ impl ImageFill {
width,
height,
keep_aspect_ratio,
+ transform: None,
+ }
+ }
+
+ pub fn new_with_transform(
+ id: Uuid,
+ opacity: u8,
+ width: i32,
+ height: i32,
+ keep_aspect_ratio: bool,
+ transform: Option,
+ ) -> Self {
+ Self {
+ id,
+ opacity,
+ width,
+ height,
+ keep_aspect_ratio,
+ transform,
}
}
@@ -157,6 +185,10 @@ impl ImageFill {
pub fn height(&self) -> i32 {
self.height
}
+
+ pub fn transform(&self) -> Option<&ImageFillTransform> {
+ self.transform.as_ref()
+ }
}
#[derive(Debug, Clone, PartialEq, Copy)]
diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs
index da6289b289..1898ed7276 100644
--- a/render-wasm/src/shapes/modifiers.rs
+++ b/render-wasm/src/shapes/modifiers.rs
@@ -12,8 +12,8 @@ use common::GetBounds;
use crate::error::Result;
use crate::shapes;
use crate::shapes::{
- ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, Shape, TransformEntry,
- TransformEntrySource, Type,
+ ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape,
+ TransformEntry, TransformEntrySource, Type,
};
use crate::state::{ShapesPoolRef, State};
use crate::uuid::Uuid;
@@ -139,36 +139,84 @@ fn calculate_bool_bounds(
Some(result)
}
-fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
- let tr = bounds.transform_matrix().unwrap_or_default();
- let tr_inv = tr.invert().unwrap_or_default();
+/// Which parts of the geometry a pixel-grid correction rounds: only the ones
+/// the transform changes, so a move keeps its dimensions and a resize keeps
+/// its anchored corner.
+#[derive(PartialEq, Debug, Clone, Copy)]
+struct SnapGeometry {
+ x: bool,
+ y: bool,
+ width: bool,
+ height: bool,
+}
- let x = bounds.min_x().round();
- let y = bounds.min_y().round();
+impl SnapGeometry {
+ /// Flags the properties that differ between the two bounds. The axis mask
+ /// in `precision` applies to the position only.
+ fn new(before: &Bounds, after: &Bounds, precision: PixelPrecision) -> Self {
+ SnapGeometry {
+ x: precision.rounds_x() && !is_close_to(before.min_x(), after.min_x()),
+ y: precision.rounds_y() && !is_close_to(before.min_y(), after.min_y()),
+ width: !is_close_to(before.width(), after.width()),
+ height: !is_close_to(before.height(), after.height()),
+ }
+ }
- let width = bounds.width();
- let height = bounds.height();
+ fn resized(&self) -> bool {
+ self.width || self.height
+ }
- let target_width = bounds.width().round();
- let target_height = bounds.height().round();
+ fn any(&self) -> bool {
+ self.x || self.y || self.resized()
+ }
+}
- let scale_width = if width > 0.1 {
- f32::max(0.01, target_width / width)
+/// Rounds a transform so the parts of the shape the gesture changed land on
+/// the pixel grid, leaving everything else exactly where it is.
+fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds, snap: SnapGeometry) {
+ // Target corner, taken before the size correction: that correction scales
+ // about the bounds center, and the translation below undoes the corner
+ // displacement it causes. An unsnapped axis targets its own value.
+ let x = if snap.x {
+ bounds.min_x().round()
} else {
- 1.0
+ bounds.min_x()
};
- let scale_height = if height > 0.1 {
- f32::max(0.01, target_height / height)
+ let y = if snap.y {
+ bounds.min_y().round()
} else {
- 1.0
+ bounds.min_y()
};
- if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
- let mut round_transform = Matrix::scale((scale_width, scale_height));
- round_transform.post_concat(&tr);
- round_transform.pre_concat(&tr_inv);
- transform.post_concat(&round_transform);
- bounds.transform_mut(&round_transform);
+ if snap.resized() {
+ let tr = bounds.transform_matrix().unwrap_or_default();
+ let tr_inv = tr.invert().unwrap_or_default();
+
+ let width = bounds.width();
+ let height = bounds.height();
+
+ // A rounded dimension is never smaller than one pixel.
+ let target_width = f32::max(1.0, width.round());
+ let target_height = f32::max(1.0, height.round());
+
+ let scale_width = if snap.width && width > 0.1 {
+ f32::max(0.01, target_width / width)
+ } else {
+ 1.0
+ };
+ let scale_height = if snap.height && height > 0.1 {
+ f32::max(0.01, target_height / height)
+ } else {
+ 1.0
+ };
+
+ if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
+ let mut round_transform = Matrix::scale((scale_width, scale_height));
+ round_transform.post_concat(&tr);
+ round_transform.pre_concat(&tr_inv);
+ transform.post_concat(&round_transform);
+ bounds.transform_mut(&round_transform);
+ }
}
let dx = x - bounds.min_x();
@@ -184,7 +232,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
#[allow(clippy::too_many_arguments)]
fn propagate_transform(
entry: TransformEntry,
- pixel_precision: bool,
+ pixel_precision: PixelPrecision,
state: &State,
entries: &mut VecDeque,
bounds: &mut HashMap,
@@ -286,8 +334,11 @@ fn propagate_transform(
}
}
- if pixel_precision {
- set_pixel_precision(&mut transform, &mut shape_bounds_after);
+ if pixel_precision.enabled() {
+ let snap = SnapGeometry::new(&shape_bounds_before, &shape_bounds_after, pixel_precision);
+ if snap.any() {
+ set_pixel_precision(&mut transform, &mut shape_bounds_after, snap);
+ }
}
if entry.propagate {
@@ -417,10 +468,15 @@ fn reflow_shape(
Ok(())
}
+/// Propagates a set of transforms through the shape tree, returning one
+/// transform per affected shape.
+///
+/// The transforms are relative to the committed geometry, so callers clear
+/// any transform modifier of their own before propagating.
pub fn propagate_modifiers(
state: &State,
modifiers: &[TransformEntry],
- pixel_precision: bool,
+ pixel_precision: PixelPrecision,
) -> Result> {
let mut entries: VecDeque<_> = modifiers
.iter()
@@ -567,6 +623,249 @@ mod tests {
assert_eq!(result.len(), 1);
}
+ #[test]
+ fn test_pixel_precision_move_keeps_size() {
+ let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
+ let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 3.7)));
+ let mut transform = Matrix::translate((5.2, 3.7));
+
+ let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
+ set_pixel_precision(&mut transform, &mut bounds_after, snap);
+
+ assert!(is_close_to(bounds_after.width(), 100.5));
+ assert!(is_close_to(bounds_after.height(), 50.3));
+ assert!(is_close_to(bounds_after.min_x(), 16.0));
+ assert!(is_close_to(bounds_after.min_y(), 24.0));
+ assert!(math::is_move_only_matrix(&transform));
+ }
+
+ #[test]
+ fn test_pixel_precision_resize_rounds_size() {
+ let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
+ let mut bounds_after = bounds.transform(&Matrix::scale((1.1, 1.1)));
+ let mut transform = Matrix::scale((1.1, 1.1));
+
+ let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
+ set_pixel_precision(&mut transform, &mut bounds_after, snap);
+
+ assert!(is_close_to(
+ bounds_after.width(),
+ bounds_after.width().round()
+ ));
+ assert!(is_close_to(
+ bounds_after.height(),
+ bounds_after.height().round()
+ ));
+ assert!(is_close_to(
+ bounds_after.min_x(),
+ bounds_after.min_x().round()
+ ));
+ assert!(is_close_to(
+ bounds_after.min_y(),
+ bounds_after.min_y().round()
+ ));
+ }
+
+ #[test]
+ fn test_propagate_pixel_precision_move_only_rounds_position() {
+ let shape_id = Uuid::new_v4();
+ let mut state = State::new();
+ state.shapes.initialize(10);
+ {
+ let shape = state.shapes.add_shape(shape_id);
+ shape.set_selrect(10.4, 20.6, 110.9, 70.9);
+ }
+
+ let entry = TransformEntry::from_input(shape_id, Matrix::translate((5.2, 3.7)));
+ let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
+
+ let transform = result
+ .iter()
+ .find(|entry| entry.id == shape_id)
+ .map(|entry| entry.transform)
+ .unwrap();
+
+ let shape = state.shapes.get(&shape_id).unwrap();
+ let bounds = shape.bounds().transform(&transform);
+
+ assert!(is_close_to(bounds.width(), 100.5));
+ assert!(is_close_to(bounds.height(), 50.3));
+ assert!(is_close_to(bounds.min_x(), 16.0));
+ assert!(is_close_to(bounds.min_y(), 24.0));
+ }
+
+ #[test]
+ fn test_propagate_pixel_precision_resize_keeps_anchored_corner() {
+ let shape_id = Uuid::new_v4();
+ let mut state = State::new();
+ state.shapes.initialize(10);
+ {
+ let shape = state.shapes.add_shape(shape_id);
+ shape.set_selrect(10.4, 20.6, 110.4, 70.6);
+ }
+
+ // Drag the bottom-right corner in small steps: the top-left corner
+ // stays put on every step.
+ for step in 1..40 {
+ let delta = step as f32 * 0.05;
+ let mut resize = Matrix::scale(((100.0 + delta) / 100.0, (50.0 + delta) / 50.0));
+ resize.post_translate(Point::new(10.4, 20.6));
+ resize.pre_translate(Point::new(-10.4, -20.6));
+
+ let entry = TransformEntry::from_input(shape_id, resize);
+ let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
+
+ let transform = result
+ .iter()
+ .find(|entry| entry.id == shape_id)
+ .map(|entry| entry.transform)
+ .unwrap();
+
+ let shape = state.shapes.get(&shape_id).unwrap();
+ let bounds = shape.bounds().transform(&transform);
+
+ assert!(
+ is_close_to(bounds.min_x(), 10.4) && is_close_to(bounds.min_y(), 20.6),
+ "corner moved to ({}, {}) at delta {}",
+ bounds.min_x(),
+ bounds.min_y(),
+ delta
+ );
+ assert!(is_close_to(bounds.width(), bounds.width().round()));
+ assert!(is_close_to(bounds.height(), bounds.height().round()));
+ }
+ }
+
+ #[test]
+ fn test_pixel_precision_only_x_leaves_y_untouched() {
+ let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
+ let mut bounds_after = bounds.transform(&Matrix::translate((5.2, 0.0)));
+ let mut transform = Matrix::translate((5.2, 0.0));
+
+ let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyX);
+ set_pixel_precision(&mut transform, &mut bounds_after, snap);
+
+ assert!(is_close_to(bounds_after.min_x(), 16.0));
+ assert!(is_close_to(bounds_after.min_y(), 20.6));
+ }
+
+ #[test]
+ fn test_pixel_precision_only_y_leaves_x_untouched() {
+ let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.4, 20.6, 100.5, 50.3));
+ let mut bounds_after = bounds.transform(&Matrix::translate((0.0, 3.7)));
+ let mut transform = Matrix::translate((0.0, 3.7));
+
+ let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::OnlyY);
+ set_pixel_precision(&mut transform, &mut bounds_after, snap);
+
+ assert!(is_close_to(bounds_after.min_x(), 10.4));
+ assert!(is_close_to(bounds_after.min_y(), 24.0));
+ }
+
+ #[test]
+ fn test_pixel_precision_resize_never_rounds_below_one_pixel() {
+ let bounds = Bounds::from_rect(&math::Rect::from_xywh(10.0, 20.0, 0.4, 0.3));
+ let mut bounds_after = bounds.transform(&Matrix::scale((1.5, 1.5)));
+ let mut transform = Matrix::scale((1.5, 1.5));
+
+ let snap = SnapGeometry::new(&bounds, &bounds_after, PixelPrecision::Both);
+ set_pixel_precision(&mut transform, &mut bounds_after, snap);
+
+ assert!(is_close_to(bounds_after.width(), 1.0));
+ assert!(is_close_to(bounds_after.height(), 1.0));
+ }
+
+ #[test]
+ fn test_propagate_pixel_precision_snaps_every_frame_of_a_gesture() {
+ let shape_id = Uuid::new_v4();
+ let mut state = State::new();
+ state.shapes.initialize(10);
+ {
+ let shape = state.shapes.add_shape(shape_id);
+ shape.set_selrect(10.4, 20.6, 110.9, 70.9);
+ }
+
+ // One frame of a drag, as the entry point runs it: clear the
+ // modifiers, propagate the delta accumulated since the gesture
+ // started, then push the result back as the active modifier, which is
+ // what the renderer draws.
+ let frame = |state: &mut State, delta: f32| {
+ state.shapes.clear_transform_modifiers();
+
+ let entry = TransformEntry::from_input(shape_id, Matrix::translate((delta, delta)));
+ let result = propagate_modifiers(state, &[entry], PixelPrecision::Both).unwrap();
+ let transform = result
+ .iter()
+ .find(|entry| entry.id == shape_id)
+ .map(|entry| entry.transform)
+ .unwrap();
+
+ let bounds = state
+ .shapes
+ .get_raw(&shape_id)
+ .unwrap()
+ .bounds()
+ .transform(&transform);
+
+ state.set_modifiers(HashMap::from([(shape_id, transform)]));
+ bounds
+ };
+
+ // Every frame lands on the pixel grid and keeps the size.
+ for step in 1..40 {
+ let bounds = frame(&mut state, step as f32 * 0.35);
+
+ assert!(
+ is_close_to(bounds.min_x(), bounds.min_x().round())
+ && is_close_to(bounds.min_y(), bounds.min_y().round()),
+ "shape landed off the pixel grid at ({}, {}) on frame {}",
+ bounds.min_x(),
+ bounds.min_y(),
+ step
+ );
+ assert!(is_close_to(bounds.width(), 100.5));
+ assert!(is_close_to(bounds.height(), 50.3));
+ }
+ }
+
+ #[test]
+ fn test_propagate_pixel_precision_resize_only_rounds_the_changed_dimension() {
+ let shape_id = Uuid::new_v4();
+ let mut state = State::new();
+ state.shapes.initialize(10);
+ {
+ let shape = state.shapes.add_shape(shape_id);
+ shape.set_selrect(10.4, 20.6, 110.9, 70.9);
+ }
+
+ // Drag the right edge: the width lands on the grid, the height and
+ // the top-left corner stay put.
+ let mut resize = Matrix::scale((103.3 / 100.5, 1.0));
+ resize.post_translate(Point::new(10.4, 20.6));
+ resize.pre_translate(Point::new(-10.4, -20.6));
+
+ let entry = TransformEntry::from_input(shape_id, resize);
+ let result = propagate_modifiers(&state, &[entry], PixelPrecision::Both).unwrap();
+
+ let transform = result
+ .iter()
+ .find(|entry| entry.id == shape_id)
+ .map(|entry| entry.transform)
+ .unwrap();
+
+ let bounds = state
+ .shapes
+ .get_raw(&shape_id)
+ .unwrap()
+ .bounds()
+ .transform(&transform);
+
+ assert!(is_close_to(bounds.width(), 103.0));
+ assert!(is_close_to(bounds.height(), 50.3));
+ assert!(is_close_to(bounds.min_x(), 10.4));
+ assert!(is_close_to(bounds.min_y(), 20.6));
+ }
+
#[test]
fn test_group_bounds() {
let parent_id = Uuid::new_v4();
diff --git a/render-wasm/src/shapes/shape_to_path.rs b/render-wasm/src/shapes/shape_to_path.rs
index 32e058dcb6..a797e52837 100644
--- a/render-wasm/src/shapes/shape_to_path.rs
+++ b/render-wasm/src/shapes/shape_to_path.rs
@@ -102,9 +102,18 @@ fn fix_radius(
}
pub fn rect_segments(shape: &Shape, corners: Option) -> Vec {
+ transform_segments(rect_segments_local(shape, corners), shape)
+}
+
+/// Axis-aligned rect path in selrect space (no `shape.transform`).
+///
+/// Use when the caller already applies [`Shape::centered_transform`] on the
+/// canvas (e.g. SVG leaf export); [`rect_segments`] would bake the transform
+/// into the path and double-rotate.
+pub fn rect_segments_local(shape: &Shape, corners: Option) -> Vec {
let sr = shape.selrect;
- let segments = if let Some([r1, r2, r3, r4]) = corners {
+ if let Some([r1, r2, r3, r4]) = corners {
let (r1, r2, r3, r4) = fix_radius(r1, r2, r3, r4, sr.width(), sr.height());
let p1 = (sr.x(), sr.y() + r1.y);
@@ -139,9 +148,7 @@ pub fn rect_segments(shape: &Shape, corners: Option) -> Vec {
Segment::LineTo(p4),
Segment::Close,
]
- };
-
- transform_segments(segments, shape)
+ }
}
fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) {
@@ -151,6 +158,11 @@ fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) {
}
pub fn circle_segments(shape: &Shape) -> Vec {
+ transform_segments(circle_segments_local(shape), shape)
+}
+
+/// Circle path in selrect space (no `shape.transform`). See [`rect_segments_local`].
+pub fn circle_segments_local(shape: &Shape) -> Vec {
let sr = shape.selrect;
let c = BEZIER_CIRCLE_C;
let c1x = sr.x() + (sr.width() / 2.0 * (1.0 - c));
@@ -168,15 +180,13 @@ pub fn circle_segments(shape: &Shape) -> Vec {
let p3 = (mx, ey);
let p4 = (sr.x(), my);
- let segments = vec![
+ vec![
Segment::MoveTo(p1),
Segment::CurveTo(((c2x, p1.1), (p2.0, c1y), p2)),
Segment::CurveTo(((p2.0, c2y), (c2x, p3.1), p3)),
Segment::CurveTo(((c1x, p3.1), (p4.0, c2y), p4)),
Segment::CurveTo(((p4.0, c1y), (c1x, p1.1), p1)),
- ];
-
- transform_segments(segments, shape)
+ ]
}
fn join_paths(path: Path, other: Path) -> Path {
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index f0f2fa9f1e..f8009e91be 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -1,4 +1,4 @@
-use crate::render::text::calculate_decoration_metrics;
+use crate::render::text::decoration_segments;
use crate::{
math::{Bounds, Matrix, Rect},
render::{default_font, DEFAULT_EMOJI_FONT},
@@ -320,6 +320,18 @@ pub struct TextDecorationSegment {
pub width: f32,
}
+impl TextDecorationSegment {
+ /// The bar to paint, centered on `y`.
+ pub fn rect(&self) -> Rect {
+ Rect::new(
+ self.left,
+ self.y - self.thickness / 2.0,
+ self.left + self.width,
+ self.y + self.thickness / 2.0,
+ )
+ }
+}
+
fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
match valign {
VerticalAlign::Center => (container_h - content_h) / 2.0,
@@ -796,13 +808,13 @@ impl TextContent {
&self,
use_shadow: Option,
) -> Vec {
- self.paragraph_builders(use_shadow, false, None, None)
+ self.paragraph_builders(use_shadow, false, None, None, None, None)
}
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec {
- self.paragraph_builders(None, true, None, None)
+ self.paragraph_builders(None, true, None, None, None, None)
}
/// Maximum number of stacked fills across every span in this text block.
@@ -821,7 +833,42 @@ impl TextContent {
&self,
layer_from_bottom: usize,
) -> Vec {
- self.paragraph_builders(None, false, None, Some(layer_from_bottom))
+ self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
+ }
+
+ /// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
+ /// this layer is an image in `skip_image_ids` get transparent paint (those
+ /// fills are re-emitted as linked SVG `` elements).
+ pub fn paragraph_builder_group_for_fill_layer_skipping_images(
+ &self,
+ layer_from_bottom: usize,
+ skip_image_ids: &HashSet,
+ ) -> Vec {
+ self.paragraph_builders(
+ None,
+ false,
+ None,
+ Some(layer_from_bottom),
+ None,
+ Some(skip_image_ids),
+ )
+ }
+
+ /// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
+ /// the given image — used as an SVG `` for linked image fills.
+ pub fn paragraph_builder_group_opaque_for_image_layer(
+ &self,
+ layer_from_bottom: usize,
+ image_id: Uuid,
+ ) -> Vec {
+ self.paragraph_builders(
+ None,
+ false,
+ None,
+ None,
+ Some((layer_from_bottom, image_id)),
+ None,
+ )
}
fn paragraph_builders(
@@ -830,6 +877,8 @@ impl TextContent {
opaque: bool,
align_override: Option,
fill_layer: Option,
+ opaque_image_layer: Option<(usize, Uuid)>,
+ skip_image_ids: Option<&HashSet>,
) -> Vec {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
@@ -843,15 +892,63 @@ impl TextContent {
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
- let remove_alpha =
- opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
- let text_style = span.to_style_with_paint(
- &self.bounds(),
- fallback_fonts,
- remove_alpha,
- paragraph.line_height(),
- fill_layer,
- );
+ let text_style = if let Some((layer, image_id)) = opaque_image_layer {
+ let mut style = span.to_style(
+ &self.bounds(),
+ fallback_fonts,
+ false,
+ paragraph.line_height(),
+ );
+ let mut paint = paint::Paint::default();
+ match span.fills_from_bottom(layer) {
+ Some(shapes::Fill::Image(img)) if img.id() == image_id => {
+ paint.set_color(skia::Color::BLACK);
+ paint.set_alpha(255);
+ }
+ _ => {
+ paint.set_color(skia::Color::TRANSPARENT);
+ }
+ }
+ style.set_foreground_paint(&paint);
+ style
+ } else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
+ let skip_span = matches!(
+ span.fills_from_bottom(layer),
+ Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
+ );
+ if skip_span {
+ let mut style = span.to_style(
+ &self.bounds(),
+ fallback_fonts,
+ false,
+ paragraph.line_height(),
+ );
+ let mut paint = paint::Paint::default();
+ paint.set_color(skia::Color::TRANSPARENT);
+ style.set_foreground_paint(&paint);
+ style
+ } else {
+ let remove_alpha =
+ opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
+ span.to_style_with_paint(
+ &self.bounds(),
+ fallback_fonts,
+ remove_alpha,
+ paragraph.line_height(),
+ fill_layer,
+ )
+ }
+ } else {
+ let remove_alpha =
+ opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
+ span.to_style_with_paint(
+ &self.bounds(),
+ fallback_fonts,
+ remove_alpha,
+ paragraph.line_height(),
+ fill_layer,
+ )
+ };
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
@@ -871,8 +968,14 @@ impl TextContent {
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
- let mut measure_builders =
- self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
+ let mut measure_builders = self.paragraph_builders(
+ None,
+ false,
+ Some(skia::textlayout::TextAlign::Left),
+ None,
+ None,
+ None,
+ );
let normalized_line_height =
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
@@ -1464,6 +1567,15 @@ pub struct TextSpan {
}
impl TextSpan {
+ /// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
+ pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
+ if layer < self.fills.len() {
+ Some(&self.fills[self.fills.len() - 1 - layer])
+ } else {
+ None
+ }
+ }
+
#[allow(clippy::too_many_arguments)]
pub fn new(
text: String,
@@ -1725,68 +1837,12 @@ pub fn calculate_text_layout_data(
for (i, group_paragraphs) in built_groups.into_iter().enumerate() {
// For each paragraph in the group (e.g., fill, stroke, etc.)
for skia_paragraph in group_paragraphs.into_iter() {
- // Calculate text decorations for this paragraph
- let mut decorations = Vec::new();
- let line_metrics = skia_paragraph.get_line_metrics();
- for line in &line_metrics {
- let style_metrics: Vec<_> = line
- .get_style_metrics(line.start_index..line.end_index)
- .into_iter()
- .collect();
- let line_baseline = y_accum + line.baseline as f32;
- let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
- calculate_decoration_metrics(&style_metrics, line_baseline);
- for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
- let text_style = &style_metric.text_style;
- let style_end = style_metrics
- .get(i + 1)
- .map(|(next_i, _)| *next_i)
- .unwrap_or(line.end_index);
- let seg_start = (*style_start).max(line.start_index);
- let seg_end = style_end.min(line.end_index);
- if seg_start >= seg_end {
- continue;
- }
- let rects = skia_paragraph.get_rects_for_range(
- seg_start..seg_end,
- skia::textlayout::RectHeightStyle::Tight,
- skia::textlayout::RectWidthStyle::Tight,
- );
- let (segment_width, actual_x_offset) = if !rects.is_empty() {
- let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
- let skia_x_offset = rects
- .first()
- .map(|r| r.rect.left - line.left as f32)
- .unwrap_or(0.0);
- (total_width, skia_x_offset)
- } else {
- (0.0, 0.0)
- };
- let text_left = x + line.left as f32 + actual_x_offset;
- let text_width = segment_width;
- use skia::textlayout::TextDecoration;
- if text_style.decoration().ty == TextDecoration::UNDERLINE {
- decorations.push(TextDecorationSegment {
- kind: TextDecoration::UNDERLINE,
- text_style: (*text_style).clone(),
- y: underline_y.unwrap_or(line_baseline),
- thickness: max_underline_thickness,
- left: text_left,
- width: text_width,
- });
- }
- if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
- decorations.push(TextDecorationSegment {
- kind: TextDecoration::LINE_THROUGH,
- text_style: (*text_style).clone(),
- y: strike_y.unwrap_or(line_baseline),
- thickness: max_strike_thickness,
- left: text_left,
- width: text_width,
- });
- }
- }
- }
+ let decorations = text_paragraphs
+ .get(i)
+ .map(|text_paragraph| {
+ decoration_segments(&skia_paragraph, text_paragraph, x, y_accum)
+ })
+ .unwrap_or_default();
paragraph_layouts.push(ParagraphLayout {
paragraph: skia_paragraph,
x,
diff --git a/render-wasm/src/shapes/transform.rs b/render-wasm/src/shapes/transform.rs
index b0ff2a52d0..98224a0e97 100644
--- a/render-wasm/src/shapes/transform.rs
+++ b/render-wasm/src/shapes/transform.rs
@@ -5,18 +5,59 @@ use crate::utils::{uuid_from_u32_quartet, uuid_to_u32_quartet};
use crate::uuid::Uuid;
use skia::Matrix;
+/// Axes the pixel grid rounds. An axis-locked drag rounds only the axis it
+/// moves along.
+#[derive(PartialEq, Debug, Clone, Copy)]
+pub enum PixelPrecision {
+ Disabled,
+ Both,
+ OnlyX,
+ OnlyY,
+}
+
+impl PixelPrecision {
+ pub fn enabled(&self) -> bool {
+ *self != PixelPrecision::Disabled
+ }
+
+ pub fn rounds_x(&self) -> bool {
+ matches!(self, PixelPrecision::Both | PixelPrecision::OnlyX)
+ }
+
+ pub fn rounds_y(&self) -> bool {
+ matches!(self, PixelPrecision::Both | PixelPrecision::OnlyY)
+ }
+}
+
+impl From for PixelPrecision {
+ fn from(value: u8) -> Self {
+ match value {
+ 1 => PixelPrecision::Both,
+ 2 => PixelPrecision::OnlyX,
+ 3 => PixelPrecision::OnlyY,
+ _ => PixelPrecision::Disabled,
+ }
+ }
+}
+
#[derive(PartialEq, Debug, Clone)]
pub enum Modifier {
- Transform(TransformEntry, bool),
+ Transform(TransformEntry, PixelPrecision),
Reflow(Uuid, bool),
}
impl Modifier {
pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self {
- Modifier::Transform(TransformEntry::from_propagate(id, transform), false)
+ Modifier::Transform(
+ TransformEntry::from_propagate(id, transform),
+ PixelPrecision::Disabled,
+ )
}
pub fn parent(id: Uuid, transform: Matrix) -> Self {
- Modifier::Transform(TransformEntry::parent(id, transform), false)
+ Modifier::Transform(
+ TransformEntry::parent(id, transform),
+ PixelPrecision::Disabled,
+ )
}
pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
Modifier::Reflow(id, force_reflow)
diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs
index d09bb109b2..f39825ebaf 100644
--- a/render-wasm/src/state/shapes_pool.rs
+++ b/render-wasm/src/state/shapes_pool.rs
@@ -403,6 +403,20 @@ impl ShapesPoolImpl {
/// gone, but if we don't touch their tiles they keep pointing at the
/// previous modified position and the tile texture cache may serve stale
/// pixels.
+ /// Drops the transform modifiers, keeping structure and scale-content
+ /// entries, so the pool serves committed geometry again. Called before
+ /// propagating a new set of transforms, which are relative to that
+ /// geometry.
+ pub fn clear_transform_modifiers(&mut self) {
+ if self.modifiers.is_empty() {
+ return;
+ }
+
+ self.clean_shape_cache();
+ self.modifiers = HashMap::default();
+ self.modifier_uuids.clear();
+ }
+
pub fn clean_all(&mut self) -> Vec {
self.clean_shape_cache();
diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs
index c4084ea983..5d2a6e29e2 100644
--- a/render-wasm/src/wasm/fills.rs
+++ b/render-wasm/src/wasm/fills.rs
@@ -187,4 +187,30 @@ mod tests {
assert_eq!(bytes[0], 0x03);
assert_eq!(shapes::Fill::from(RawFillData::from(bytes)), fill);
}
+
+ #[test]
+ fn test_image_fill_with_transform_round_trip() {
+ let transform = shapes::ImageFillTransform {
+ x: 0.1,
+ y: -0.2,
+ width: 1.5,
+ height: 2.0,
+ };
+ let image_fill = shapes::ImageFill::new_with_transform(
+ crate::uuid::Uuid::nil(),
+ 0xcc,
+ 400,
+ 300,
+ false,
+ Some(transform),
+ );
+ let fill = shapes::Fill::Image(image_fill);
+ let raw_fill =
+ RawFillData::try_from(&fill).expect("image fill with transform must be serializable");
+ let bytes = <[u8; RAW_FILL_DATA_SIZE]>::from(raw_fill);
+
+ assert_eq!(bytes[0], 0x03);
+ let deserialized = shapes::Fill::from(RawFillData::from(bytes));
+ assert_eq!(deserialized, fill);
+ }
}
diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs
index ce14511220..e6b2e634c5 100644
--- a/render-wasm/src/wasm/fills/image.rs
+++ b/render-wasm/src/wasm/fills/image.rs
@@ -30,6 +30,7 @@ fn touch_shapes_with_image(state: &mut State, image_id: Uuid) {
}
const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0;
+const FLAG_HAS_TRANSFORM: u8 = 1 << 1;
const IMAGE_IDS_SIZE: usize = 32;
const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag
@@ -43,20 +44,30 @@ pub struct RawImageFillData {
d: u32,
opacity: u8,
flags: u8,
- // 16-bit padding here, reserved for future use
+ _pad: u16,
width: i32,
height: i32,
+ transform_x: f32,
+ transform_y: f32,
+ transform_w: f32,
+ transform_h: f32,
}
impl From<&ImageFill> for RawImageFillData {
fn from(image_fill: &ImageFill) -> Self {
let id = image_fill.id();
let (a, b, c, d) = crate::utils::uuid_to_u32_quartet(&id);
- let flags = if image_fill.keep_aspect_ratio() {
+ let mut flags = if image_fill.keep_aspect_ratio() {
FLAG_KEEP_ASPECT_RATIO
} else {
0
};
+ let (tx, ty, tw, th) = if let Some(tf) = image_fill.transform() {
+ flags |= FLAG_HAS_TRANSFORM;
+ (tf.x, tf.y, tf.width, tf.height)
+ } else {
+ (0.0, 0.0, 1.0, 1.0)
+ };
Self {
a,
@@ -65,8 +76,13 @@ impl From<&ImageFill> for RawImageFillData {
d,
opacity: image_fill.opacity(),
flags,
+ _pad: 0,
width: image_fill.width(),
height: image_fill.height(),
+ transform_x: tx,
+ transform_y: ty,
+ transform_w: tw,
+ transform_h: th,
}
}
}
@@ -75,13 +91,24 @@ impl From for ImageFill {
fn from(value: RawImageFillData) -> Self {
let id = uuid_from_u32_quartet(value.a, value.b, value.c, value.d);
let keep_aspect_ratio = value.flags & FLAG_KEEP_ASPECT_RATIO != 0;
+ let transform = if value.flags & FLAG_HAS_TRANSFORM != 0 {
+ Some(crate::shapes::ImageFillTransform {
+ x: value.transform_x,
+ y: value.transform_y,
+ width: value.transform_w,
+ height: value.transform_h,
+ })
+ } else {
+ None
+ };
- Self::new(
+ Self::new_with_transform(
id,
value.opacity,
value.width,
value.height,
keep_aspect_ratio,
+ transform,
)
}
}
@@ -140,6 +167,22 @@ pub extern "C" fn store_image() -> Result<()> {
Ok(())
}
+/// Registers the public URL an image was loaded from for SVG export.
+///
+/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as
+/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`).
+#[no_mangle]
+#[wasm_error]
+pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
+ let id = uuid_from_u32_quartet(a, b, c, d);
+ let url_bytes = mem::bytes();
+ let url = String::from_utf8(url_bytes)
+ .map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?;
+ mem::free_bytes()?;
+ get_resources().images.set_source_url(id, url);
+ Ok(())
+}
+
/// Stores an image from an existing WebGL texture, avoiding re-decoding
/// Expected memory layout:
/// - bytes 0-15: shape UUID
diff --git a/render-wasm/src/wasm/transforms.rs b/render-wasm/src/wasm/transforms.rs
index 87989c545b..e8c92da247 100644
--- a/render-wasm/src/wasm/transforms.rs
+++ b/render-wasm/src/wasm/transforms.rs
@@ -76,7 +76,7 @@ impl From for TransformEntry {
#[no_mangle]
#[wasm_error]
-pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8> {
+pub extern "C" fn propagate_modifiers(pixel_precision: u8) -> Result<*mut u8> {
let bytes = mem::bytes();
let entries: Vec = bytes
@@ -85,7 +85,8 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> Result<*mut u8>
.collect::>>()?;
with_state!(state, {
- let result = shapes::propagate_modifiers(state, &entries, pixel_precision)?;
+ state.shapes.clear_transform_modifiers();
+ let result = shapes::propagate_modifiers(state, &entries, pixel_precision.into())?;
Ok(mem::write_vec(result))
})
}
diff --git a/scripts/clean-node-modules b/scripts/clean-node-modules
new file mode 100755
index 0000000000..8d17b5f213
--- /dev/null
+++ b/scripts/clean-node-modules
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Remove node_modules directories across the monorepo's pnpm workspaces:
+# the repo root, all module workspaces, and all workspace member packages.
+#
+# Use it when node_modules are stale or corrupted and a clean reinstall is
+# cheaper than debugging. Reinstall afterwards with `pnpm install` in each
+# workspace root (root, backend, common, docs, exporter, frontend, library,
+# mcp, media-processor, plugins, render-wasm).
+#
+# external/ (vendored dependency trees) and .opencode/ are always ignored.
+# The pnpm content-addressable store lives at /.pnpm-store, outside
+# node_modules, and survives a default clean. `--store` removes it too;
+# the next install re-downloads what it held.
+
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/clean-node-modules [options]
+
+Remove every node_modules directory in the pnpm workspaces: the repo root,
+all module workspaces, and all workspace member packages.
+
+Options:
+ -n, --dry-run Print what would be removed without deleting anything.
+ --store Also delete the shared pnpm store at /.pnpm-store.
+ The next install re-downloads everything it held.
+ -h, --help Show this help.
+
+external/ (vendored dependency trees) and .opencode/ are always ignored.
+EOF
+}
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DRY_RUN=0
+WITH_STORE=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -n | --dry-run) DRY_RUN=1 ;;
+ --store) WITH_STORE=1 ;;
+ -h | --help) usage; exit 0 ;;
+ *) {
+ echo "error: unknown option: $1" >&2
+ usage >&2
+ exit 64
+ } ;;
+ esac
+ shift
+done
+
+cd "$ROOT"
+
+if [[ $WITH_STORE -eq 1 && -d .pnpm-store ]]; then
+ size="$(du -sh .pnpm-store | cut -f1)"
+ if [[ $DRY_RUN -eq 1 ]]; then
+ echo "would remove: ./.pnpm-store (pnpm store, $size)"
+ else
+ echo "removing pnpm store: $ROOT/.pnpm-store ($size); the next install re-downloads it"
+ rm -rf .pnpm-store
+ fi
+fi
+
+# -prune stops the descent, so only the top-most node_modules of each tree
+# matches; nested dependency copies inside it die with their parent.
+mapfile -t dirs < <(
+ find . \( -name .git -o -name external -o -name .opencode \) -type d -prune \
+ -o \( -name node_modules -type d -prune -print \)
+)
+
+if [[ ${#dirs[@]} -eq 0 ]]; then
+ echo "no node_modules directories found"
+ exit 0
+fi
+
+cleaned=0
+for dir in "${dirs[@]}"; do
+ if [[ $DRY_RUN -eq 1 ]]; then
+ echo "would remove: $dir"
+ else
+ rm -rf "$dir"
+ echo "removed: $dir"
+ fi
+ cleaned=$((cleaned + 1))
+done
+
+if [[ $DRY_RUN -eq 1 ]]; then
+ echo "$cleaned node_modules directories found"
+else
+ echo "$cleaned node_modules directories cleaned"
+fi
diff --git a/scripts/replace-copyright.sh b/scripts/replace-copyright.sh
index f910d0a20d..93fc8476ec 100755
--- a/scripts/replace-copyright.sh
+++ b/scripts/replace-copyright.sh
@@ -173,9 +173,11 @@ main() {
fi
done
- # Get tracked files matching our extensions
- local files
- files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" || true)
+ # Get tracked files matching our extensions, excluding this script itself
+ # (it mentions the search string in its own usage docs).
+ local files script_name
+ script_name=$(basename "$0")
+ files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" | grep -v -F "$script_name" || true)
if [[ -z "$files" ]]; then
log_warn "No tracked files found matching extensions: ${EXTENSIONS}"